@nm-lang/cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -140
- package/bin/bridge-host.mjs +78 -0
- package/bin/bridge-worker.mjs +27 -0
- package/bin/nm.mjs +3 -1
- package/dist/index.js +26 -26
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/python/README.md +69 -0
- package/python/examples/training.ipynb +78 -0
- package/python/nm_bridge.py +464 -0
package/dist/version.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type NMLanguageReleaseChange = {
|
|
|
29
29
|
};
|
|
30
30
|
export declare const nmVersionManifest: {
|
|
31
31
|
readonly languageVersion: "0.2.0";
|
|
32
|
-
readonly packageVersion: "0.
|
|
32
|
+
readonly packageVersion: "0.5.0";
|
|
33
33
|
readonly irVersion: "0.1";
|
|
34
34
|
readonly qasm3SidecarVersion: "0.3";
|
|
35
35
|
readonly learningPackageVersion: "0.1";
|
package/dist/version.js
CHANGED
|
@@ -2,7 +2,7 @@ export const NM_LANGUAGE_PREVIEW_READINESS_FORMAT = "nm-language-preview-readine
|
|
|
2
2
|
export const NM_LANGUAGE_PREVIEW_READINESS_VERSION = "0.1";
|
|
3
3
|
export const nmVersionManifest = {
|
|
4
4
|
languageVersion: "0.2.0",
|
|
5
|
-
packageVersion: "0.
|
|
5
|
+
packageVersion: "0.5.0",
|
|
6
6
|
irVersion: "0.1",
|
|
7
7
|
qasm3SidecarVersion: "0.3",
|
|
8
8
|
learningPackageVersion: "0.1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nm-lang/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Command-line tools for formatting, linting, testing, workspace snapshots, execution, and exports for N/M quantum programs.",
|
|
6
6
|
"repository": {
|
|
@@ -44,11 +44,12 @@
|
|
|
44
44
|
"files": [
|
|
45
45
|
"bin",
|
|
46
46
|
"dist",
|
|
47
|
+
"python",
|
|
47
48
|
"LICENSE.txt",
|
|
48
49
|
"NOTICE.txt"
|
|
49
50
|
],
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@nm-lang/core": "0.
|
|
52
|
+
"@nm-lang/core": "0.5.0",
|
|
52
53
|
"jiti": "^2.7.0"
|
|
53
54
|
},
|
|
54
55
|
"scripts": {
|
package/python/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# N/M Python/Jupyter bridge — CLI 0.5.0 / CLI köprüsü
|
|
2
|
+
|
|
3
|
+
Included in @nm-lang/cli 0.5.0; not a separate PyPI distribution.
|
|
4
|
+
@nm-lang/cli 0.5.0 içinde bulunur; ayrı bir PyPI dağıtımı değildir.
|
|
5
|
+
Python 3.10+ syntax; standard library only. Jupyter is installed separately.
|
|
6
|
+
Add this directory to `sys.path`, then `from nm_bridge import NMClient, NMResult`.
|
|
7
|
+
The default client runs the adjacent `../bin/nm.mjs` with Node directly.
|
|
8
|
+
|
|
9
|
+
Default Node lookup accepts only absolute PATH directories outside the working
|
|
10
|
+
directory and resolves an absolute executable path. Empty/relative entries and
|
|
11
|
+
cwd aliases are skipped on every supported Python version. Missing Node raises
|
|
12
|
+
`NM-PY-011`; no bare-name fallback is used. Explicit `command=` is trusted input
|
|
13
|
+
and bypasses this lookup unchanged. Trust the selected executable and absolute
|
|
14
|
+
PATH directories; records never grant permissions or execute themselves.
|
|
15
|
+
Varsayılan Node araması yalnız çalışma dizini dışındaki mutlak PATH dizinlerini
|
|
16
|
+
kullanır; boş/göreli girdiler ve çalışma dizinine çözülen yollar atlanır. Node
|
|
17
|
+
bulunamazsa `NM-PY-011` verilir. Açık `command=` güvenilir girdidir ve değiştirilmez;
|
|
18
|
+
seçilen programın güvenilirliği çağırana aittir. Kayıt okumak izin vermez veya çalıştırmaz.
|
|
19
|
+
|
|
20
|
+
Pipe I/O and EOF waiting share the bounded deadline, including custom launchers
|
|
21
|
+
whose descendants retain output handles. No I/O threads survive a call. Windows
|
|
22
|
+
Python 3.10/3.11 uses a stdlib Win32 compatibility path; only the direct process
|
|
23
|
+
is terminated, so this is not a process-tree sandbox.
|
|
24
|
+
Giriş/çıkış ve EOF aynı süre sınırına bağlıdır; çağrı sonrası I/O iş parçacığı
|
|
25
|
+
kalmaz. Özel başlatıcının alt süreci boruyu açık tutarsa eksik sonuç kabul edilmez.
|
|
26
|
+
Windows Python 3.10/3.11 desteklenir; yalnız doğrudan başlatılan süreç sonlandırılır.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
client = NMClient(experimental=("hamiltonianEvolution", "measurementGrouping"), locale="tr")
|
|
30
|
+
record = client.train(source, options={"steps": 2, "targetAccuracy": {"absoluteError": 0.5}})
|
|
31
|
+
record # Jupyter table / Jupyter tablosu
|
|
32
|
+
data = record.to_json() # Complete original source + options + result
|
|
33
|
+
restored = NMResult.from_json(data) # Data only; no execution or permission grant
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Incoming JSON and compact saved records are limited to 4 MiB. Compact saving preserves
|
|
37
|
+
wire number spellings; Python's float formatting cannot grow an accepted record past
|
|
38
|
+
the limit. Pretty output may exceed the limit and is rejected.
|
|
39
|
+
Gelen JSON ve kompakt kayıt sınırı 4 MiB'dır. Kompakt kayıt özgün sayı gösterimini
|
|
40
|
+
korur; Python'ın sayı biçimlendirmesi geçerli kaydı sınırın üzerine büyütmez.
|
|
41
|
+
Girintili çıktı sınırı aşarsa reddedilir.
|
|
42
|
+
|
|
43
|
+
`source` is your N/M source text / kendi N/M kaynak metninizdir. `examples/training.ipynb`
|
|
44
|
+
contains runnable TR/EN sources with one total 4096-shot budget, separate pilot
|
|
45
|
+
measurements and target precision. Outputs are not pre-executed or saved.
|
|
46
|
+
|
|
47
|
+
Feature permissions come from the client, never the record. Replay with
|
|
48
|
+
`client.execute(restored.request)` requires independent explicit permissions.
|
|
49
|
+
Source snapshots are not checkpoints. Precision targets do not certify convergence.
|
|
50
|
+
`targetAccuracy.requiredShotsEstimate` estimates final-cost shots from current
|
|
51
|
+
group proportions and confidence assumptions, excluding adaptive pilots. It is not
|
|
52
|
+
a total training budget or a guarantee; estimates above 4096, unsafe integer counts
|
|
53
|
+
and numeric resolution limits have explicit states. No extra shots are taken.
|
|
54
|
+
Original source, training options, shot accounting and confidence assumptions are
|
|
55
|
+
retained; output is escaped rather than treated as executable HTML.
|
|
56
|
+
|
|
57
|
+
İzinler istemciden gelir; kaydı okumak izin vermez veya çalıştırmaz. Kaynak,
|
|
58
|
+
eğitim seçenekleri, ölçüm bütçesi ve güven aralığı varsayımları korunur. Pilotlar
|
|
59
|
+
bütçeye dahildir; hassasiyet hedefi yakınsama belgesi değildir. `locale="tr"` ve
|
|
60
|
+
`locale="en"` desteklenir. Süre/boyut sınırları aşılırsa kısmi başarı üretilmez.
|
|
61
|
+
`targetAccuracy.requiredShotsEstimate` son maliyet için gereken ölçümleri bilgi
|
|
62
|
+
amaçlı tahmin eder; pilotlar dahil edilmez. Toplam eğitim bütçesi veya garanti değildir.
|
|
63
|
+
4096 sınırı, güvenli tamsayı aralığı ve sayısal çözünürlük engelleri açıkça gösterilir;
|
|
64
|
+
ek ölçüm yapılmaz.
|
|
65
|
+
|
|
66
|
+
Full guide / Tam rehber:
|
|
67
|
+
`docs/NM_PYTHON_JUPYTER_BRIDGE_GUIDE.md` in the repository. This preview is not a
|
|
68
|
+
separately published PyPI distribution. It is covered by the adjacent npm package's
|
|
69
|
+
LICENSE.txt and NOTICE.txt; source-code rights remain reserved.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nbformat": 4,
|
|
3
|
+
"nbformat_minor": 5,
|
|
4
|
+
"metadata": {
|
|
5
|
+
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
|
6
|
+
"language_info": {"name": "python"}
|
|
7
|
+
},
|
|
8
|
+
"cells": [
|
|
9
|
+
{"cell_type": "markdown", "id": "scope", "metadata": {}, "source": [
|
|
10
|
+
"# N/M Python/Jupyter — CLI 0.5.0 / CLI bridge\n",
|
|
11
|
+
"Henüz yayımlanmadı. Önce bu dalın CLI'sini derleyin; Python 3.10+ ve Node gerekir. / Not published. Build the branch CLI first; Python 3.10+ and Node are required.\n",
|
|
12
|
+
"Her eğitim toplam 4096 ölçüm kullanır; pilotlar dahildir. Hedef hassasiyet yakınsama belgesi değildir. / Each training call uses 4096 total shots including pilots. Precision is not a convergence certificate.\n",
|
|
13
|
+
"Çıktılar önceden çalıştırılıp kaydedilmedi. / Outputs have not been pre-executed or saved."
|
|
14
|
+
]},
|
|
15
|
+
{"cell_type": "code", "id": "setup", "metadata": {}, "execution_count": null, "outputs": [], "source": [
|
|
16
|
+
"import os, sys\n",
|
|
17
|
+
"from pathlib import Path\n",
|
|
18
|
+
"# Explicit library location override / Açık kütüphane dizini seçimi\n",
|
|
19
|
+
"candidates = [Path(os.environ['NM_BRIDGE_PYTHON_PATH'])] if 'NM_BRIDGE_PYTHON_PATH' in os.environ else [Path.cwd(), Path.cwd().parent, Path.cwd() / 'packages/nm-cli/python']\n",
|
|
20
|
+
"adapter_dir = next((p.resolve() for p in candidates if (p / 'nm_bridge.py').is_file()), None)\n",
|
|
21
|
+
"assert adapter_dir is not None, 'Set NM_BRIDGE_PYTHON_PATH to the CLI python directory / CLI python dizinini belirtin'\n",
|
|
22
|
+
"sys.path.insert(0, str(adapter_dir))\n",
|
|
23
|
+
"from nm_bridge import NMClient, NMResult\n",
|
|
24
|
+
"features = ('hamiltonianEvolution', 'measurementGrouping')\n",
|
|
25
|
+
"options = {'steps': 2, 'learningRate': 0.17, 'optimizer': 'adam', 'objective': 'minimize', 'objectiveTarget': 'energy', 'gradientMethod': 'parameter-shift', 'measurementAllocation': {'method': 'adaptive', 'pilotShots': 8}, 'targetAccuracy': {'absoluteError': 1e-9}}"
|
|
26
|
+
]},
|
|
27
|
+
{"cell_type": "markdown", "id": "turkish", "metadata": {}, "source": [
|
|
28
|
+
"## Türkçe\n",
|
|
29
|
+
"İzinleri çağıran açıkça seçer. Kaynak, seçenekler, grup tohumları ve güven aralıkları tam kayıtta korunur. / Caller permissions are explicit; source, options, group seeds and confidence bounds are retained."
|
|
30
|
+
]},
|
|
31
|
+
{"cell_type": "code", "id": "train-tr", "metadata": {}, "execution_count": null, "outputs": [], "source": [
|
|
32
|
+
"source_tr = '''module notebook_egitimi;\n",
|
|
33
|
+
"@dil(\"tr\");\n",
|
|
34
|
+
"@seed(27);\n",
|
|
35
|
+
"@shots_budget(4096);\n",
|
|
36
|
+
"param theta:Angle=0;\n",
|
|
37
|
+
"fn main(){\n",
|
|
38
|
+
" let q=qreg[1];\n",
|
|
39
|
+
" observable Hamiltonian=theta*X(q[0])+0.25*theta*I;\n",
|
|
40
|
+
" evolve Hamiltonian for 0.4 adımlar 1 order 1 on q;\n",
|
|
41
|
+
" let energy=expect Y(q[0])+0.5*Z(q[0]);\n",
|
|
42
|
+
" return q;\n",
|
|
43
|
+
"}\n",
|
|
44
|
+
"// Özgün kaynak: İı çğ üş 😀 <script>source is data</script>\n",
|
|
45
|
+
"'''\n",
|
|
46
|
+
"record_tr = NMClient(experimental=features, locale='tr').train(source_tr, options=options)\n",
|
|
47
|
+
"assert record_tr.success, record_tr.to_json()\n",
|
|
48
|
+
"assert record_tr.result['groupedMeasurements']['usedShots'] == 4096\n",
|
|
49
|
+
"assert record_tr.result['groupedMeasurements']['targetAccuracy']['status'] == 'not-met'\n",
|
|
50
|
+
"record_tr"
|
|
51
|
+
]},
|
|
52
|
+
{"cell_type": "markdown", "id": "english", "metadata": {}, "source": [
|
|
53
|
+
"## English\n",
|
|
54
|
+
"The same options and one inclusive budget apply. Held-out estimates do not reuse pilots. No source string becomes executable HTML."
|
|
55
|
+
]},
|
|
56
|
+
{"cell_type": "code", "id": "train-en", "metadata": {}, "execution_count": null, "outputs": [], "source": [
|
|
57
|
+
"source_en = source_tr.replace('@dil(\"tr\");\\n', '').replace('notebook_egitimi', 'notebook_training').replace('adımlar 1', 'steps 1')\n",
|
|
58
|
+
"client_en = NMClient(experimental=features, locale='en')\n",
|
|
59
|
+
"record_en = client_en.train(source_en, options=options)\n",
|
|
60
|
+
"assert record_en.success, record_en.to_json()\n",
|
|
61
|
+
"evidence = record_en.result['groupedMeasurements']\n",
|
|
62
|
+
"assert evidence['usedShots'] == evidence['adaptive']['pilotUsedShots'] + evidence['adaptive']['estimationUsedShots'] == 4096\n",
|
|
63
|
+
"assert evidence['targetAccuracy']['finalCost'] == evidence['costs'][-1]\n",
|
|
64
|
+
"record_en"
|
|
65
|
+
]},
|
|
66
|
+
{"cell_type": "code", "id": "data-only-replay", "metadata": {}, "execution_count": null, "outputs": [], "source": [
|
|
67
|
+
"restored = NMResult.from_json(record_en.to_json())\n",
|
|
68
|
+
"assert restored.record == record_en.record\n",
|
|
69
|
+
"assert restored.request['source'] == source_en\n",
|
|
70
|
+
"# Deserialization grants no permission / Kaydı okumak izin vermez.\n",
|
|
71
|
+
"denied = NMClient(locale='en').execute(restored.request)\n",
|
|
72
|
+
"assert not denied.success and denied.record['error']['code'] == 'NM-PY-003'\n",
|
|
73
|
+
"replayed = client_en.execute(restored.request)\n",
|
|
74
|
+
"assert replayed.result == record_en.result\n",
|
|
75
|
+
"print('Round-trip + independent replay permissions verified / Kayıt ve bağımsız yeniden yürütme izinleri doğrulandı')"
|
|
76
|
+
]}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""Bounded local N/M JSON/CLI bridge (Python 3.10+, standard library only).
|
|
2
|
+
|
|
3
|
+
Not a Python evaluator, checkpoint, hardware connector or permission-bearing IR.
|
|
4
|
+
The adjacent npm CLI must be built/installed. No network or shell is used here.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import html
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
|
|
19
|
+
FORMAT = "nm-python-bridge"
|
|
20
|
+
VERSION = 1
|
|
21
|
+
MAX_REQUEST_BYTES = 262_144
|
|
22
|
+
MAX_RESPONSE_BYTES = 4_194_304
|
|
23
|
+
MAX_STDERR_BYTES = 65_536
|
|
24
|
+
MAX_REQUEST_DEPTH = 32
|
|
25
|
+
MAX_RESPONSE_DEPTH = 64
|
|
26
|
+
|
|
27
|
+
_COPY = {
|
|
28
|
+
"en": {
|
|
29
|
+
"input": "Invalid or oversized JSON bridge input.",
|
|
30
|
+
"transport": "The local CLI did not return a valid complete bridge response.",
|
|
31
|
+
"timeout": "Bridge deadline exceeded; the process was killed. No completed precision evidence is available.",
|
|
32
|
+
"cancel": "Bridge cancelled; the process was killed. No completed result is available.",
|
|
33
|
+
"output": "Bridge output exceeded its byte limit; no partial result was accepted.",
|
|
34
|
+
"node": "Node.js was not found in an absolute PATH directory outside the working directory. Install Node.js or supply a trusted command sequence.",
|
|
35
|
+
"title": "N/M local bridge", "status": "Execution succeeded", "failed": "Execution failed",
|
|
36
|
+
"rejected": "Request rejected", "shots": "Total shots used / budget", "remaining": "Unallocated shots",
|
|
37
|
+
"pilot": "Pilot shots (allocation only)", "held_out": "Held-out estimation shots",
|
|
38
|
+
"cost": "Final sampled cost", "observed": "Latest observed cost (not a final-parameter certificate)",
|
|
39
|
+
"interval": "Simultaneous 95% confidence interval", "se": "Standard error",
|
|
40
|
+
"precision": "Final-cost precision target", "bound": "Absolute error bound", "floor": "Numeric resolution floor",
|
|
41
|
+
"required_shots": "Estimated shots needed for final cost", "above_limit": "Above the 4096 limit",
|
|
42
|
+
"estimate_assumptions": "Estimate assumptions",
|
|
43
|
+
"estimate_large": "Estimate exceeds the safe integer range", "estimate_resolution": "Target cannot be certified at the current numeric resolution",
|
|
44
|
+
"estimate_note": "Assumes final estimation-group proportions and the current confidence calculation; final-cost shots only, not a total training budget or a guarantee. No extra shots are taken.",
|
|
45
|
+
"met": "met", "not-met": "not met", "incomplete": "incomplete", "source": "Original source",
|
|
46
|
+
"notice": "Local record, not a signature. Confidence applies to the recorded sampling assumptions; it is not a convergence, optimum, hardware or numerical-model certificate. Replaying source requires independent caller permission.",
|
|
47
|
+
"field": "Field", "value": "Value",
|
|
48
|
+
"unavailable": "unavailable",
|
|
49
|
+
},
|
|
50
|
+
"tr": {
|
|
51
|
+
"input": "JSON köprü girdisi geçersiz veya boyut sınırını aşıyor.",
|
|
52
|
+
"transport": "Yerel CLI geçerli ve tam bir köprü yanıtı döndürmedi.",
|
|
53
|
+
"timeout": "Köprü süre sınırı aşıldı; işlem sonlandırıldı. Tamamlanmış hassasiyet kanıtı yoktur.",
|
|
54
|
+
"cancel": "Köprü iptal edildi; işlem sonlandırıldı. Tamamlanmış sonuç yoktur.",
|
|
55
|
+
"output": "Köprü çıktısı bayt sınırını aştı; kısmi sonuç kabul edilmedi.",
|
|
56
|
+
"node": "Çalışma dizini dışındaki mutlak PATH dizinlerinde Node.js bulunamadı. Node.js kurun veya güvenilir bir komut dizisi verin.",
|
|
57
|
+
"title": "N/M yerel köprüsü", "status": "Yürütme başarılı", "failed": "Yürütme başarısız",
|
|
58
|
+
"rejected": "İstek reddedildi", "shots": "Kullanılan toplam ölçüm / bütçe", "remaining": "Dağıtılmamış ölçüm",
|
|
59
|
+
"pilot": "Pilot ölçümler (yalnız dağılım için)", "held_out": "Ayrı değerlendirme ölçümleri",
|
|
60
|
+
"cost": "Son örnek maliyet", "observed": "Son gözlenen maliyet (son parametrelerin belgesi değildir)",
|
|
61
|
+
"interval": "Eşzamanlı %95 güven aralığı", "se": "Standart hata",
|
|
62
|
+
"precision": "Son maliyet hassasiyet hedefi", "bound": "Mutlak hata sınırı", "floor": "Sayısal çözünürlük alt sınırı",
|
|
63
|
+
"required_shots": "Son maliyet için tahmini ölçüm ihtiyacı", "above_limit": "4096 sınırının üzerinde",
|
|
64
|
+
"estimate_assumptions": "Tahmin varsayımları",
|
|
65
|
+
"estimate_large": "Tahmin güvenli tamsayı aralığının üzerinde", "estimate_resolution": "Hedef mevcut sayısal çözünürlükle doğrulanamaz",
|
|
66
|
+
"estimate_note": "Son değerlendirmedeki grup oranları ve mevcut güven hesabı varsayılır; yalnız son maliyet ölçümleridir, toplam eğitim bütçesi veya garanti değildir. Ek ölçüm alınmaz.",
|
|
67
|
+
"met": "karşılandı", "not-met": "karşılanmadı", "incomplete": "tamamlanmadı", "source": "Özgün kaynak",
|
|
68
|
+
"notice": "Yerel kayıttır; imza değildir. Güven aralığı kayıtlı örnekleme varsayımları için geçerlidir; yakınsama, optimum, donanım veya sayısal model belgesi değildir. Yeniden yürütme bağımsız çağıran izni gerektirir.",
|
|
69
|
+
"field": "Alan", "value": "Değer",
|
|
70
|
+
"unavailable": "belirlenmedi",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NMBridgeError(RuntimeError):
|
|
76
|
+
"""Transport/validation failure. It never contains a partial success result."""
|
|
77
|
+
def __init__(self, code: str, key: str, locale: str = "en"):
|
|
78
|
+
self.code = code
|
|
79
|
+
super().__init__(f"{code}: {_COPY[locale][key]}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_node(locale: str) -> str:
|
|
83
|
+
"""Search explicit absolute PATH entries, never Windows' implicit cwd search."""
|
|
84
|
+
try:
|
|
85
|
+
working_directory = Path.cwd().resolve(strict=True)
|
|
86
|
+
except (OSError, RuntimeError):
|
|
87
|
+
raise NMBridgeError("NM-PY-011", "node", locale) from None
|
|
88
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep):
|
|
89
|
+
if not entry or not Path(entry).is_absolute():
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
directory = Path(entry).resolve(strict=True)
|
|
93
|
+
if directory == working_directory:
|
|
94
|
+
continue
|
|
95
|
+
executable = (directory / ("node.exe" if os.name == "nt" else "node")).resolve(strict=True)
|
|
96
|
+
if executable.parent != working_directory and executable.is_file() and os.access(executable, os.X_OK):
|
|
97
|
+
return str(executable)
|
|
98
|
+
except (OSError, RuntimeError, ValueError):
|
|
99
|
+
continue
|
|
100
|
+
raise NMBridgeError("NM-PY-011", "node", locale)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _strict_json(text: str, limit: int, *, safe_integers: bool = False, depth_limit: int = MAX_REQUEST_DEPTH):
|
|
104
|
+
if not isinstance(text, str) or len(text.encode("utf-8")) > limit:
|
|
105
|
+
raise ValueError("JSON byte limit")
|
|
106
|
+
depth, quoted, escaped = 0, False, False
|
|
107
|
+
for char in text:
|
|
108
|
+
if quoted:
|
|
109
|
+
if escaped:
|
|
110
|
+
escaped = False
|
|
111
|
+
elif char == "\\":
|
|
112
|
+
escaped = True
|
|
113
|
+
elif char == '"':
|
|
114
|
+
quoted = False
|
|
115
|
+
elif char == '"':
|
|
116
|
+
quoted = True
|
|
117
|
+
elif char in "[{":
|
|
118
|
+
depth += 1
|
|
119
|
+
if depth > depth_limit:
|
|
120
|
+
raise ValueError("JSON depth limit")
|
|
121
|
+
elif char in "]}":
|
|
122
|
+
depth -= 1
|
|
123
|
+
|
|
124
|
+
def finite(value):
|
|
125
|
+
number = float(value)
|
|
126
|
+
if not math.isfinite(number) or safe_integers and number.is_integer() and abs(number) > 9007199254740991:
|
|
127
|
+
raise ValueError("Non-finite JSON number")
|
|
128
|
+
return number
|
|
129
|
+
|
|
130
|
+
def integer(value):
|
|
131
|
+
number = int(value)
|
|
132
|
+
if abs(number) > 9007199254740991:
|
|
133
|
+
if safe_integers:
|
|
134
|
+
raise ValueError("Unsafe integer JSON number")
|
|
135
|
+
# Runtime numbers are IEEE-754, not arbitrary-precision Python ints.
|
|
136
|
+
# JS may serialize an integral double as its shortest decimal,
|
|
137
|
+
# e.g. 1000000000000000100 denotes the double 1000000000000000128.
|
|
138
|
+
return finite(value)
|
|
139
|
+
return number
|
|
140
|
+
|
|
141
|
+
def pairs(items):
|
|
142
|
+
result = {}
|
|
143
|
+
for key, value in items:
|
|
144
|
+
if key in result:
|
|
145
|
+
raise ValueError("Duplicate JSON key")
|
|
146
|
+
result[key] = value
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def invalid(_value):
|
|
150
|
+
raise ValueError("Non-JSON constant")
|
|
151
|
+
|
|
152
|
+
return json.loads(text, parse_float=finite, parse_int=integer, parse_constant=invalid, object_pairs_hook=pairs)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _compact_json_text(text: str) -> str:
|
|
156
|
+
"""Compact validated JSON without rewriting number or string representations."""
|
|
157
|
+
characters, quoted, escaped = [], False, False
|
|
158
|
+
for char in text:
|
|
159
|
+
if quoted:
|
|
160
|
+
characters.append(char)
|
|
161
|
+
if escaped:
|
|
162
|
+
escaped = False
|
|
163
|
+
elif char == "\\":
|
|
164
|
+
escaped = True
|
|
165
|
+
elif char == '"':
|
|
166
|
+
quoted = False
|
|
167
|
+
elif char == '"':
|
|
168
|
+
characters.append(char)
|
|
169
|
+
quoted = True
|
|
170
|
+
elif char not in " \t\r\n":
|
|
171
|
+
characters.append(char)
|
|
172
|
+
return "".join(characters)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _set_nonblocking(stream):
|
|
176
|
+
if os.name != "nt" or sys.version_info >= (3, 12):
|
|
177
|
+
os.set_blocking(stream.fileno(), False)
|
|
178
|
+
return stream.read, stream.write
|
|
179
|
+
# os.set_blocking gained Windows pipe support in 3.12. Keep the adapter's
|
|
180
|
+
# Python 3.10+ contract using the same anonymous-pipe mode on older Windows.
|
|
181
|
+
import ctypes
|
|
182
|
+
import msvcrt
|
|
183
|
+
from ctypes import wintypes
|
|
184
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
185
|
+
set_state = kernel.SetNamedPipeHandleState
|
|
186
|
+
set_state.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD),
|
|
187
|
+
ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD)]
|
|
188
|
+
set_state.restype = wintypes.BOOL
|
|
189
|
+
mode = wintypes.DWORD(1) # PIPE_NOWAIT, byte mode
|
|
190
|
+
handle = msvcrt.get_osfhandle(stream.fileno())
|
|
191
|
+
if not set_state(handle, ctypes.byref(mode), None, None):
|
|
192
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
193
|
+
kernel.ReadFile.argtypes = kernel.WriteFile.argtypes = [wintypes.HANDLE, ctypes.c_void_p,
|
|
194
|
+
wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p]
|
|
195
|
+
kernel.ReadFile.restype = kernel.WriteFile.restype = wintypes.BOOL
|
|
196
|
+
|
|
197
|
+
# Older Python CRT reads map ERROR_NO_DATA to EINVAL rather than EAGAIN.
|
|
198
|
+
# Interpret the actual nonblocking pipe result instead of hiding all EINVALs.
|
|
199
|
+
def read(size):
|
|
200
|
+
buffer, count = ctypes.create_string_buffer(size), wintypes.DWORD()
|
|
201
|
+
if kernel.ReadFile(handle, buffer, size, ctypes.byref(count), None):
|
|
202
|
+
return buffer.raw[:count.value]
|
|
203
|
+
error = ctypes.get_last_error()
|
|
204
|
+
if error == 232: # ERROR_NO_DATA: pipe still open, no bytes ready
|
|
205
|
+
return None
|
|
206
|
+
if error == 109: # ERROR_BROKEN_PIPE: EOF
|
|
207
|
+
return b""
|
|
208
|
+
raise ctypes.WinError(error)
|
|
209
|
+
|
|
210
|
+
def write(data):
|
|
211
|
+
raw, count = bytes(data), wintypes.DWORD()
|
|
212
|
+
buffer = ctypes.create_string_buffer(raw)
|
|
213
|
+
if kernel.WriteFile(handle, buffer, len(raw), ctypes.byref(count), None):
|
|
214
|
+
return count.value
|
|
215
|
+
error = ctypes.get_last_error()
|
|
216
|
+
if error in (109, 232):
|
|
217
|
+
raise BrokenPipeError("Bridge input pipe closed")
|
|
218
|
+
raise ctypes.WinError(error)
|
|
219
|
+
|
|
220
|
+
return read, write
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class NMResult:
|
|
224
|
+
"""Detached, round-trippable record; construction/deserialization never runs N/M."""
|
|
225
|
+
def __init__(self, record: dict):
|
|
226
|
+
self._load_record(json.dumps(record, ensure_ascii=False, allow_nan=False, separators=(",", ":")))
|
|
227
|
+
|
|
228
|
+
def _load_record(self, text: str):
|
|
229
|
+
record = _strict_json(text, MAX_RESPONSE_BYTES, depth_limit=MAX_RESPONSE_DEPTH)
|
|
230
|
+
if (not isinstance(record, dict) or record.get("format") != FORMAT
|
|
231
|
+
or type(record.get("version")) is not int or record["version"] != VERSION
|
|
232
|
+
or type(record.get("success")) is not bool or record.get("locale") not in _COPY
|
|
233
|
+
or record.get("status") not in ("completed", "rejected")):
|
|
234
|
+
raise ValueError("Invalid bridge response")
|
|
235
|
+
if record["status"] == "completed":
|
|
236
|
+
if (not isinstance(record.get("request"), dict) or not isinstance(record.get("permissions"), dict)
|
|
237
|
+
or not isinstance(record.get("result"), dict) or record["result"].get("success") is not record["success"]):
|
|
238
|
+
raise ValueError("Incomplete bridge response")
|
|
239
|
+
elif record["success"] or "result" in record or not isinstance(record.get("error"), dict):
|
|
240
|
+
raise ValueError("Invalid rejection")
|
|
241
|
+
self._record = record
|
|
242
|
+
# Python may expand valid JS numbers (1e-7 -> 1e-07). Preserve their wire
|
|
243
|
+
# representation so a bounded response remains bounded when saved/reloaded.
|
|
244
|
+
self._compact_json = _compact_json_text(text)
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_json(cls, text: str) -> NMResult:
|
|
248
|
+
result = cls.__new__(cls)
|
|
249
|
+
result._load_record(text)
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
def to_json(self, *, indent: int | None = None) -> str:
|
|
253
|
+
if indent is None:
|
|
254
|
+
return self._compact_json
|
|
255
|
+
text = json.dumps(self._record, ensure_ascii=False, allow_nan=False, indent=indent)
|
|
256
|
+
if len(text.encode("utf-8")) > MAX_RESPONSE_BYTES:
|
|
257
|
+
raise ValueError("Formatted record exceeds the JSON byte limit; use compact output")
|
|
258
|
+
return text
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def record(self) -> dict:
|
|
262
|
+
return copy.deepcopy(self._record)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def request(self) -> dict | None:
|
|
266
|
+
return copy.deepcopy(self._record.get("request"))
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def result(self) -> dict | None:
|
|
270
|
+
return copy.deepcopy(self._record.get("result"))
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def success(self) -> bool:
|
|
274
|
+
return self._record["success"]
|
|
275
|
+
|
|
276
|
+
def __repr__(self) -> str:
|
|
277
|
+
return f"NMResult(status={self._record['status']!r}, success={self.success!r})"
|
|
278
|
+
|
|
279
|
+
def _repr_html_(self) -> str:
|
|
280
|
+
"""Jupyter rich display: text is escaped, never evaluated or injected as HTML."""
|
|
281
|
+
record = self._record
|
|
282
|
+
labels = _COPY[record["locale"]]
|
|
283
|
+
def mapping(value):
|
|
284
|
+
return value if isinstance(value, dict) else {}
|
|
285
|
+
|
|
286
|
+
result = mapping(record.get("result"))
|
|
287
|
+
evidence = mapping(result.get("groupedMeasurements"))
|
|
288
|
+
rows = [(labels["field"], labels["value"])]
|
|
289
|
+
rows.append((labels["title"], labels["rejected"] if record["status"] == "rejected" else labels["status" if self.success else "failed"]))
|
|
290
|
+
error = mapping(record.get("error"))
|
|
291
|
+
if error:
|
|
292
|
+
rows.append((str(error.get("code", "")), str(error.get("message", ""))))
|
|
293
|
+
if result.get("error"):
|
|
294
|
+
rows.append((labels["failed"], result["error"]))
|
|
295
|
+
if evidence:
|
|
296
|
+
rows.extend([(labels["shots"], f"{evidence.get('usedShots')} / {evidence.get('requestedShots')}"),
|
|
297
|
+
(labels["remaining"], evidence.get("unallocatedShots"))])
|
|
298
|
+
adaptive = mapping(evidence.get("adaptive"))
|
|
299
|
+
if adaptive:
|
|
300
|
+
rows.extend([(labels["pilot"], adaptive.get("pilotUsedShots")), (labels["held_out"], adaptive.get("estimationUsedShots"))])
|
|
301
|
+
costs = evidence.get("costs") if isinstance(evidence.get("costs"), list) else []
|
|
302
|
+
if costs:
|
|
303
|
+
cost = mapping(costs[-1])
|
|
304
|
+
rows.extend([(labels["cost" if self.success else "observed"], cost.get("value")),
|
|
305
|
+
(labels["interval"], f"[{cost.get('lower')}, {cost.get('upper')}]"), (labels["se"], cost.get("standardError"))])
|
|
306
|
+
accuracy = mapping(evidence.get("targetAccuracy"))
|
|
307
|
+
if accuracy:
|
|
308
|
+
state = str(accuracy.get("status", "incomplete"))
|
|
309
|
+
rows.extend([(labels["precision"], f"{accuracy.get('absoluteError')}: {labels.get(state, state)}"),
|
|
310
|
+
(labels["bound"], accuracy.get("absoluteErrorBound")), (labels["floor"], accuracy.get("numericResolutionFloor"))])
|
|
311
|
+
estimate = mapping(accuracy.get("requiredShotsEstimate"))
|
|
312
|
+
if estimate:
|
|
313
|
+
estimated = estimate.get("shots") if estimate.get("status") == "estimated" else labels[
|
|
314
|
+
"estimate_resolution" if estimate.get("status") == "numeric-resolution-limit" else "estimate_large"]
|
|
315
|
+
if estimate.get("exceedsShotLimit") is True:
|
|
316
|
+
estimated = f"{estimated} — {labels['above_limit']}"
|
|
317
|
+
rows.extend([(labels["required_shots"], estimated), (labels["estimate_assumptions"], labels["estimate_note"])])
|
|
318
|
+
header, *body = rows
|
|
319
|
+
table = "<table><caption>" + html.escape(labels["title"]) + "</caption><thead><tr>"
|
|
320
|
+
table += "".join("<th scope=\"col\">" + html.escape(str(value)) + "</th>" for value in header)
|
|
321
|
+
table += "</tr></thead><tbody>"
|
|
322
|
+
table += "".join("<tr><th scope=\"row\">" + html.escape(str(key)) + "</th><td>" + html.escape(labels["unavailable"] if value is None else str(value)) + "</td></tr>" for key, value in body)
|
|
323
|
+
table += "</tbody></table><p>" + html.escape(labels["notice"]) + "</p>"
|
|
324
|
+
source = mapping(record.get("request")).get("source", "")
|
|
325
|
+
return table + "<details><summary>" + html.escape(labels["source"]) + "</summary><pre>" + html.escape(str(source)) + "</pre></details>"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class NMClient:
|
|
329
|
+
"""Each call launches one adjacent CLI process, with independently chosen permissions.
|
|
330
|
+
|
|
331
|
+
command is a trusted argument sequence, never a shell string. The default uses
|
|
332
|
+
an absolute Node executable from PATH and the npm package's adjacent nm.mjs.
|
|
333
|
+
Empty/relative PATH entries and the working directory are never searched.
|
|
334
|
+
"""
|
|
335
|
+
def __init__(self, *, experimental: Sequence[str] = (), locale: str = "en", timeout: float = 30,
|
|
336
|
+
command: Sequence[str] | None = None):
|
|
337
|
+
if locale not in _COPY:
|
|
338
|
+
raise ValueError("locale must be en or tr")
|
|
339
|
+
self.locale = locale
|
|
340
|
+
if (type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0.1 <= timeout <= 60
|
|
341
|
+
or isinstance(experimental, (str, bytes)) or not isinstance(experimental, Sequence)
|
|
342
|
+
or any(not isinstance(name, str) or not name.isascii() or not name.isalpha() for name in experimental)
|
|
343
|
+
or len(set(experimental)) != len(experimental)):
|
|
344
|
+
raise NMBridgeError("NM-PY-001", "input", locale)
|
|
345
|
+
if command is None:
|
|
346
|
+
command = (_resolve_node(locale), str(Path(__file__).resolve().parents[1] / "bin" / "nm.mjs"))
|
|
347
|
+
if (isinstance(command, (str, bytes)) or not isinstance(command, Sequence) or not command
|
|
348
|
+
or any(not isinstance(arg, str) or not arg or "\0" in arg for arg in command)):
|
|
349
|
+
raise NMBridgeError("NM-PY-001", "input", locale)
|
|
350
|
+
self.command, self.experimental, self.timeout = tuple(command), tuple(experimental), timeout
|
|
351
|
+
|
|
352
|
+
def check(self, source: str, *, context: Mapping | None = None) -> NMResult:
|
|
353
|
+
return self._invoke("check", source, context=context)
|
|
354
|
+
|
|
355
|
+
def run(self, source: str, *, context: Mapping | None = None) -> NMResult:
|
|
356
|
+
return self._invoke("run", source, context=context)
|
|
357
|
+
|
|
358
|
+
def train(self, source: str, *, options: Mapping | None = None, context: Mapping | None = None) -> NMResult:
|
|
359
|
+
return self._invoke("train", source, context=context, options=options)
|
|
360
|
+
|
|
361
|
+
def _invoke(self, operation, source, *, context, options=None):
|
|
362
|
+
if context is not None and not isinstance(context, Mapping) or options is not None and not isinstance(options, Mapping):
|
|
363
|
+
raise NMBridgeError("NM-PY-001", "input", self.locale)
|
|
364
|
+
request = {"format": FORMAT, "version": VERSION, "operation": operation, "source": source}
|
|
365
|
+
if context is None:
|
|
366
|
+
context = {"experimental": {name: True for name in self.experimental}}
|
|
367
|
+
request["context"] = dict(context)
|
|
368
|
+
if options is not None:
|
|
369
|
+
request["training"] = dict(options)
|
|
370
|
+
return self.execute(request)
|
|
371
|
+
|
|
372
|
+
def execute(self, request: Mapping) -> NMResult:
|
|
373
|
+
"""Replay only request data. The client's permissions are never read from it."""
|
|
374
|
+
try:
|
|
375
|
+
if not isinstance(request, Mapping):
|
|
376
|
+
raise ValueError("Request must be a JSON object")
|
|
377
|
+
payload = json.dumps(request, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
378
|
+
snapshot = _strict_json(payload, MAX_REQUEST_BYTES, safe_integers=True)
|
|
379
|
+
encoded = payload.encode("utf-8")
|
|
380
|
+
except (ValueError, TypeError, RecursionError, UnicodeError) as error:
|
|
381
|
+
raise NMBridgeError("NM-PY-001", "input", self.locale) from error
|
|
382
|
+
args = [*self.command, "bridge", "--locale", self.locale, "--timeout-ms", str(int(self.timeout * 1000))]
|
|
383
|
+
for name in self.experimental:
|
|
384
|
+
args.extend(("--allow-feature", name))
|
|
385
|
+
stdout, returncode = self._exchange(args, encoded)
|
|
386
|
+
try:
|
|
387
|
+
response = NMResult.from_json(stdout.decode("utf-8", errors="strict").removesuffix("\n"))
|
|
388
|
+
if returncode != (0 if response.success else 1):
|
|
389
|
+
raise ValueError("Exit/result mismatch")
|
|
390
|
+
if response.record["locale"] != self.locale:
|
|
391
|
+
raise ValueError("Locale mismatch")
|
|
392
|
+
if response.record["status"] == "completed" and (response.request != snapshot
|
|
393
|
+
or response.record["permissions"].get("authorized") != {name: True for name in self.experimental}):
|
|
394
|
+
raise ValueError("Source/options/authority mismatch")
|
|
395
|
+
return response
|
|
396
|
+
except (ValueError, TypeError, RecursionError, UnicodeError) as error:
|
|
397
|
+
raise NMBridgeError("NM-PY-010", "transport", self.locale) from error
|
|
398
|
+
|
|
399
|
+
def _exchange(self, args, payload):
|
|
400
|
+
try:
|
|
401
|
+
process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
402
|
+
shell=False, bufsize=0, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
403
|
+
except OSError as error:
|
|
404
|
+
raise NMBridgeError("NM-PY-010", "transport", self.locale) from error
|
|
405
|
+
output = bytearray()
|
|
406
|
+
# Reserve one of the two cleanup seconds for reaping the direct process.
|
|
407
|
+
# EOF is part of the deadline too: a custom launcher's child may inherit
|
|
408
|
+
# the pipes even after that launcher exits. No reader/writer threads or
|
|
409
|
+
# buffered stream locks may outlive this call.
|
|
410
|
+
deadline = time.monotonic() + self.timeout + 1
|
|
411
|
+
readers = {process.stdout: [MAX_RESPONSE_BYTES + 1, 0], process.stderr: [MAX_STDERR_BYTES, 0]}
|
|
412
|
+
position = 0
|
|
413
|
+
try:
|
|
414
|
+
_, write = _set_nonblocking(process.stdin)
|
|
415
|
+
read = {stream: _set_nonblocking(stream)[0] for stream in readers}
|
|
416
|
+
while readers or process.poll() is None:
|
|
417
|
+
if time.monotonic() >= deadline:
|
|
418
|
+
raise NMBridgeError("NM-PY-008", "timeout", self.locale)
|
|
419
|
+
progressed = False
|
|
420
|
+
if not process.stdin.closed:
|
|
421
|
+
try:
|
|
422
|
+
written = write(memoryview(payload)[position:position + 65536])
|
|
423
|
+
position += written or 0
|
|
424
|
+
progressed = bool(written)
|
|
425
|
+
if position == len(payload):
|
|
426
|
+
process.stdin.close()
|
|
427
|
+
except BrokenPipeError:
|
|
428
|
+
process.stdin.close()
|
|
429
|
+
for stream, state in list(readers.items()):
|
|
430
|
+
chunk = read[stream](65536)
|
|
431
|
+
if chunk is None:
|
|
432
|
+
continue
|
|
433
|
+
progressed = True
|
|
434
|
+
if not chunk:
|
|
435
|
+
del readers[stream]
|
|
436
|
+
stream.close()
|
|
437
|
+
continue
|
|
438
|
+
state[1] += len(chunk)
|
|
439
|
+
if state[1] > state[0]:
|
|
440
|
+
raise NMBridgeError("NM-PY-006", "output", self.locale)
|
|
441
|
+
if stream is process.stdout:
|
|
442
|
+
output.extend(chunk)
|
|
443
|
+
if not progressed:
|
|
444
|
+
time.sleep(min(0.005, max(0, deadline - time.monotonic())))
|
|
445
|
+
except KeyboardInterrupt as error:
|
|
446
|
+
raise NMBridgeError("NM-PY-009", "cancel", self.locale) from error
|
|
447
|
+
except OSError as error:
|
|
448
|
+
raise NMBridgeError("NM-PY-010", "transport", self.locale) from error
|
|
449
|
+
finally:
|
|
450
|
+
if process.poll() is None:
|
|
451
|
+
try:
|
|
452
|
+
process.kill()
|
|
453
|
+
except OSError:
|
|
454
|
+
pass
|
|
455
|
+
try:
|
|
456
|
+
process.wait(timeout=1)
|
|
457
|
+
except subprocess.TimeoutExpired:
|
|
458
|
+
pass
|
|
459
|
+
for stream in (process.stdin, process.stdout, process.stderr):
|
|
460
|
+
try:
|
|
461
|
+
stream.close()
|
|
462
|
+
except OSError:
|
|
463
|
+
pass
|
|
464
|
+
return bytes(output), process.returncode
|