@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/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.3.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.3.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.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.3.0",
52
+ "@nm-lang/core": "0.5.0",
52
53
  "jiti": "^2.7.0"
53
54
  },
54
55
  "scripts": {
@@ -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