nativegate 0.1.0__py3-none-any.whl

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.
Files changed (50) hide show
  1. nativegate/__init__.py +1 -0
  2. nativegate/__main__.py +4 -0
  3. nativegate/buildinfo.py +344 -0
  4. nativegate/cli.py +2007 -0
  5. nativegate/config.py +991 -0
  6. nativegate/declared_invariants.py +565 -0
  7. nativegate/discovery.py +167 -0
  8. nativegate/driverbuild.py +626 -0
  9. nativegate/drivers/__init__.py +5 -0
  10. nativegate/drivers/cpp.py +616 -0
  11. nativegate/drivers/fortran.py +507 -0
  12. nativegate/generators/__init__.py +0 -0
  13. nativegate/generators/cmake_gen.py +101 -0
  14. nativegate/generators/docker_gen.py +614 -0
  15. nativegate/generators/error_gen.py +104 -0
  16. nativegate/generators/f2py_gen.py +91 -0
  17. nativegate/generators/gateway_gen.py +110 -0
  18. nativegate/generators/golden_gen.py +50 -0
  19. nativegate/generators/k8s_gen.py +212 -0
  20. nativegate/generators/mcp_gen.py +281 -0
  21. nativegate/generators/middleware_gen.py +717 -0
  22. nativegate/generators/pybind_gen.py +406 -0
  23. nativegate/generators/pyproject_gen.py +61 -0
  24. nativegate/generators/python_pkg_gen.py +1164 -0
  25. nativegate/generators/test_gen.py +160 -0
  26. nativegate/golden.py +747 -0
  27. nativegate/invariants.py +532 -0
  28. nativegate/ir.py +789 -0
  29. nativegate/lattice.py +350 -0
  30. nativegate/locking.py +216 -0
  31. nativegate/oracle.py +904 -0
  32. nativegate/parsers/__init__.py +0 -0
  33. nativegate/parsers/cpp.py +105 -0
  34. nativegate/parsers/cpp_ast.py +1652 -0
  35. nativegate/parsers/cpp_regex.py +812 -0
  36. nativegate/parsers/fixed_form.py +868 -0
  37. nativegate/parsers/fortran.py +157 -0
  38. nativegate/parsers/fortran_fparser.py +1116 -0
  39. nativegate/parsers/fortran_regex.py +686 -0
  40. nativegate/preprocess.py +335 -0
  41. nativegate/structural_invariants.py +762 -0
  42. nativegate/suggest.py +208 -0
  43. nativegate/templates/__init__.py +20 -0
  44. nativegate/templates/golden_test_template.py +248 -0
  45. nativegate/wire.py +438 -0
  46. nativegate-0.1.0.dist-info/METADATA +547 -0
  47. nativegate-0.1.0.dist-info/RECORD +50 -0
  48. nativegate-0.1.0.dist-info/WHEEL +5 -0
  49. nativegate-0.1.0.dist-info/entry_points.txt +3 -0
  50. nativegate-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,762 @@
1
+ """T10 — structural invariants: `finite`, `total`, `no_error_flag`,
2
+ `idempotent`, `order_independent` (design-verification-layers.md section 3,
3
+ in particular 3.2's structural table and 3.5's mutator scoping / fresh
4
+ process / setup replay).
5
+
6
+ These five properties need no domain declaration beyond `state:` (T8's
7
+ `StateConfig`) and no lattice authoring beyond `ranges:` -- they fall
8
+ straight out of the IR plus T9's lattice (`lattice.build_entry_lattice`).
9
+ That is what makes them "structural" rather than "declared" (spec section
10
+ 3.2's own distinction; the declared vocabulary -- `bounds`, `monotone`,
11
+ `sum_to_one`, ... -- is T11's job).
12
+
13
+ Two evaluation strategies, per spec section 3.5:
14
+
15
+ * `finite`, `total`, `no_error_flag` run **in the already-imported
16
+ process** the caller hands in (`package`), because nothing about them
17
+ needs isolation from prior calls -- they are checking the current call's
18
+ own outcome, not whether a *previous* call leaked into it. Each point is
19
+ still preceded by a fresh replay of the declared `setup` sequence (spec
20
+ section 3.5: "every property evaluation runs after the `setup` sequence,
21
+ replayed with the arguments recorded in `golden.json`").
22
+ * `idempotent` and `order_independent` run in a **fresh subprocess per
23
+ sequence**, because what they are testing IS cross-call leakage --
24
+ running them in the caller's own process could not tell "no hidden
25
+ state" from "hidden state, but this interpreter happens to already be in
26
+ the right state from an earlier check". See "The subprocess worker"
27
+ below for the wire format between parent and worker.
28
+
29
+ Mutator scoping (spec section 3.5)
30
+ -----------------------------------
31
+
32
+ `idempotent` is checked for every entry point NOT in `state.mutating`;
33
+ `order_independent` uses only non-`mutating` routines as the interposed
34
+ `g`, and checks every non-`mutating` `f`. Applying either property to a
35
+ declared mutator (`pvt_set_fluid` on petro_api) would condemn the API's own
36
+ contract -- that state IS the point of the routine -- so mutators are
37
+ excluded from both checks entirely, on both sides of the pairing.
38
+
39
+ An *undeclared* mutator does not escape this: it still gets used as `g`
40
+ against every non-mutating `f`, and if it actually mutates shared state,
41
+ `order_independent` fails and says so -- see `_ORDER_INDEPENDENT_HINT`,
42
+ whose wording ("the fix ... is adding the routine to `mutating` in
43
+ review") is load-bearing per spec section 3.5's own text and is asserted
44
+ verbatim in tests.
45
+
46
+ `error_flag` exemption (design decision, not literal spec text)
47
+ ------------------------------------------------------------------
48
+
49
+ The declared `state.error_flag` accessor (T8's `StateConfig.error_flag` --
50
+ e.g. petro_api's `last_error`/`PVTERR`, which reads AND CLEARS the stored
51
+ error code) is excluded from `idempotent` the same way a `mutating` routine
52
+ is, and excluded from being chosen as the interposed `g` routine in
53
+ `order_independent` (it may still appear as the `f` under test, same as any
54
+ other non-mutating routine, since nothing about clear-on-read makes *that*
55
+ suspect). Calling a clear-on-read accessor twice in a row legitimately
56
+ returns different bits on the second call -- the first call returns
57
+ whatever was stored, the second returns the cleared value -- so checking
58
+ `idempotent` against it literally, or using it to perturb another routine's
59
+ state in `order_independent`, would fail on every run for a reason that has
60
+ nothing to do with an actual bug. `design-verification-layers.md` §3.5 does
61
+ not say this explicitly; this is the same "the check would condemn the
62
+ API's own contract" reasoning already applied to `mutating` above, extended
63
+ to a second kind of intentional statefulness. See `StateConfig`'s docstring
64
+ in `config.py` for the mirrored note there.
65
+
66
+ The subprocess worker
67
+ ----------------------
68
+
69
+ `idempotent`/`order_independent` need a scripted call sequence executed in
70
+ a brand-new interpreter, with the result of specific calls reported back.
71
+ Rather than reusing T2's wire protocol (`wire.py`) -- built for *bits from
72
+ native driver stdout*, one value per line, no notion of "run these N calls
73
+ in order and tell me about calls 2 and 4" -- this module defines its own,
74
+ much smaller, JSON-in/JSON-out protocol:
75
+
76
+ * **in**: a JSON file (path passed as argv) shaped
77
+ ``{"sys_path": [...], "module": "...", "calls": [{"name": ..., "arguments":
78
+ [...]}, ...], "observe": [<index>, ...]}`` -- `sys_path` entries are
79
+ inserted so `module` (an extension or a plain fixture module) can be
80
+ imported, `calls` run in order exactly as `golden.invoke` would run them,
81
+ and `observe` names which calls' results to report back.
82
+ * **out**: one line of JSON on stdout,
83
+ ``{"ok": true, "observations": [{"index": ..., "value": ...}, ...]}`` on
84
+ success or ``{"ok": false, "error": "..."}`` on any exception -- printed
85
+ once, at the end, so a stray print from imported code cannot be mistaken
86
+ for the payload (the parent only parses the *last* non-empty stdout
87
+ line).
88
+
89
+ JSON was chosen over reusing the wire line format because the worker's
90
+ payload is a scripted call sequence and a per-call result map, not a flat
91
+ stream of `<key, slot, value>` triples with no notion of "run twice, tell
92
+ me about the second run"; forcing that shape onto T2's protocol would have
93
+ meant inventing new slot/key conventions with no native-code counterpart to
94
+ keep them honest. Bitwise comparison of the reported values still goes
95
+ through T2's `wire.pack_float` (see `_values_equal`) -- only the envelope
96
+ around the values is new, not the comparison itself.
97
+
98
+ Every subprocess launch runs under `buildinfo.pinned_environment()` (spec
99
+ section 2.8 / section 4 rule 3), merged over the parent's own environment
100
+ so the worker can still be found on `sys.path`/`PYTHONPATH`.
101
+ """
102
+
103
+ from __future__ import annotations
104
+
105
+ import importlib
106
+ import json
107
+ import math
108
+ import os
109
+ import subprocess
110
+ import sys
111
+ import tempfile
112
+ from dataclasses import dataclass
113
+ from pathlib import Path
114
+ from typing import TYPE_CHECKING, Sequence
115
+
116
+ from . import buildinfo, golden, lattice, wire
117
+
118
+ if TYPE_CHECKING:
119
+ from .config import VerificationConfig
120
+ from .ir import ModuleIR
121
+
122
+ # spec section 3.5: the byte-for-byte guidance a legitimate order_independent
123
+ # failure calls for. Quoted (paraphrased, not verbatim, since the spec gives
124
+ # guidance rather than a fixed string) in every such failure's message, and
125
+ # asserted against directly in tests.
126
+ _ORDER_INDEPENDENT_HINT = (
127
+ "this looks like an UNDECLARED mutator: if {g!r} is genuinely supposed "
128
+ "to change state that {f!r} reads, the fix is adding {g!r} to "
129
+ "`state.mutating` in nativegate.yaml (in review) -- not changing this "
130
+ "check."
131
+ )
132
+
133
+
134
+ class WorkerError(RuntimeError):
135
+ """The fresh-process worker could not be run, or crashed outright.
136
+
137
+ Distinct from a *property* failure (a `PointFailure`): this is "the
138
+ harness could not even ask the question at this point" -- e.g. the
139
+ worker process itself failed to start, or produced no parseable
140
+ output -- and is surfaced as a failure of the property being checked
141
+ (see `_check_idempotent`/`_check_order_independent`), never silently
142
+ skipped.
143
+ """
144
+
145
+
146
+ # --- results -----------------------------------------------------------
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class PointFailure:
151
+ """One lattice point (or worker run) at which a property failed."""
152
+
153
+ point: str
154
+ detail: str
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class PropertyOutcome:
159
+ """One property's result for one entry-point key."""
160
+
161
+ property: str
162
+ function: str
163
+ points_checked: int
164
+ failures: tuple[PointFailure, ...] = ()
165
+
166
+ @property
167
+ def status(self) -> str:
168
+ return "pass" if not self.failures else "fail"
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class StructuralInvariantResults:
173
+ outcomes: tuple[PropertyOutcome, ...] = ()
174
+
175
+ @property
176
+ def passed(self) -> bool:
177
+ return all(o.status == "pass" for o in self.outcomes)
178
+
179
+ def failures(self) -> list[PropertyOutcome]:
180
+ return [o for o in self.outcomes if o.status == "fail"]
181
+
182
+ def by_property(self, name: str) -> list[PropertyOutcome]:
183
+ return [o for o in self.outcomes if o.property == name]
184
+
185
+
186
+ # --- how the fresh-process worker imports the target --------------------
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class SubprocessTarget:
191
+ """How `idempotent`/`order_independent`'s worker imports the module
192
+ under test in a fresh interpreter.
193
+
194
+ `module_name` is passed to `importlib.import_module` inside the
195
+ worker; `sys_path` are directories inserted (at the front) before that
196
+ import, so a built extension or a plain fixture module that is not
197
+ already importable can still be found. `attr_path` is an optional chain
198
+ of attribute lookups applied after the import -- f2py nests routines
199
+ under `<extension>.<fortran module name>.<routine>` (see
200
+ `ir.FunctionDef.fortran_module`'s docstring) rather than at the
201
+ extension's top level, so a caller whose entry points live under a
202
+ `module ... end module` block passes e.g. `("petro_api",)` here to reach
203
+ them, the same way `services/petro_api/python/petro_api/__init__.py`'s
204
+ generated flattening does.
205
+ """
206
+
207
+ module_name: str
208
+ sys_path: tuple[str, ...] = ()
209
+ attr_path: tuple[str, ...] = ()
210
+
211
+
212
+ # --- point selection ------------------------------------------------------
213
+
214
+
215
+ def _points_for(entry_lattice: "lattice.EntryLattice", points_limit: int | None):
216
+ """Every lattice point for one entry, plus the base (recorded) point
217
+ itself -- spec section 3.4 sweeps `[lo, hi]` inclusive, which does not
218
+ generally include the golden.json base value, and the base value is the
219
+ one call every one of these properties should certainly hold at.
220
+
221
+ `points_limit` caps the number of points actually evaluated (kept in
222
+ the same order they are generated: base, then sweeps, then corners,
223
+ then scatter) -- a knob for keeping subprocess-per-point checks
224
+ (`idempotent`/`order_independent`) affordable in tests; production runs
225
+ pass `None` (no cap) so every declared point is genuinely checked, per
226
+ spec section 3.4's "at every lattice point".
227
+ """
228
+ points = [
229
+ lattice.LatticePoint(arguments=entry_lattice.base_arguments, origin="base")
230
+ ]
231
+ for sweep in entry_lattice.sweeps:
232
+ points.extend(sweep.points)
233
+ points.extend(entry_lattice.corners)
234
+ points.extend(entry_lattice.scatter)
235
+ if points_limit is not None:
236
+ points = points[:points_limit]
237
+ return points
238
+
239
+
240
+ def _describe_point(point: "lattice.LatticePoint") -> str:
241
+ if point.origin == "sweep":
242
+ return f"{point.parameter}[{point.index}] = {point.arguments!r} (sweep)"
243
+ return f"{point.origin}: {point.arguments!r}"
244
+
245
+
246
+ def build_lattices(
247
+ document: dict,
248
+ functions_by_name: dict,
249
+ verification: "VerificationConfig",
250
+ *,
251
+ n: int = lattice.DEFAULT_SWEEP_POINTS,
252
+ ) -> dict[str, "lattice.EntryLattice"]:
253
+ """T9's per-entry lattice for every golden.json entry whose function is
254
+ in `functions_by_name` and whose recorded argument count matches the
255
+ function's non-`intent(out)` parameter count.
256
+
257
+ An entry that does not match (arity mismatch, or names a function not
258
+ present in this module's IR) is silently excluded here -- diagnosing
259
+ *that* is golden's/the IR's job, not this module's; it simply is not
260
+ counted for structural checking, the same way an uncovered parameter
261
+ is not swept.
262
+
263
+ `verification.lattice` (T8's `LatticeConfig` -- `corners:`/`scatter:`
264
+ declared in `nativegate.yaml`) is consulted per entry: a function's
265
+ declared corners (`verification.lattice.corners.get(name, ())`) and the
266
+ single declared scatter seed/count are threaded straight into
267
+ `lattice.build_entry_lattice`, so a declared `lattice:` block actually
268
+ changes which points get checked here -- not only what gets reported in
269
+ `invariants.json` (see `invariants.py`'s module docstring).
270
+ """
271
+ lattices: dict[str, "lattice.EntryLattice"] = {}
272
+ for key, entry in document.get("entries", {}).items():
273
+ name = entry.get("name") or key
274
+ fn = functions_by_name.get(name)
275
+ if fn is None:
276
+ continue
277
+ parameter_names = [p.name for p in fn.parameters if p.intent != "out"]
278
+ arguments = entry.get("arguments") or []
279
+ if len(parameter_names) != len(arguments):
280
+ continue
281
+ try:
282
+ lattices[key] = lattice.build_entry_lattice(
283
+ entry,
284
+ parameter_names,
285
+ verification.ranges,
286
+ n=n,
287
+ corners=verification.lattice.corners.get(name, ()),
288
+ scatter_seed=verification.lattice.scatter.seed,
289
+ scatter_count=verification.lattice.scatter.count,
290
+ )
291
+ except ValueError:
292
+ continue
293
+ return lattices
294
+
295
+
296
+ # --- setup replay (in-process) --------------------------------------------
297
+
298
+
299
+ def _replay_setup(package, document: dict, setup_names: Sequence[str]) -> None:
300
+ """Replay `state.setup`, in order, with golden.json's recorded
301
+ arguments for each -- spec section 3.5: "every property evaluation runs
302
+ after the `setup` sequence, replayed with the arguments recorded in
303
+ `golden.json`"."""
304
+ for name in setup_names:
305
+ entry = document.get("entries", {}).get(name)
306
+ if entry is None:
307
+ raise KeyError(
308
+ f"state.setup names {name!r}, which has no golden.json entry "
309
+ "to replay it with"
310
+ )
311
+ golden.invoke(entry, package)
312
+
313
+
314
+ def _call_at_point(package, entry: dict, point: "lattice.LatticePoint"):
315
+ """Call `entry`'s routine with `point`'s arguments substituted for the
316
+ recorded ones, in-process, returning the JSON-able result. Reuses
317
+ `golden.invoke` (rather than re-deriving f2py/pybind11 calling
318
+ conventions here) so method/static/function/constructor dispatch and
319
+ array materialisation stay in exactly one place."""
320
+ synthetic = {**entry, "arguments": list(point.arguments)}
321
+ result, _effects = golden.invoke(synthetic, package)
322
+ return result
323
+
324
+
325
+ def _is_finite(value) -> bool:
326
+ if value is None or isinstance(value, (bool, int, str)):
327
+ return True
328
+ if isinstance(value, float):
329
+ return math.isfinite(value)
330
+ if isinstance(value, list):
331
+ return all(_is_finite(v) for v in value)
332
+ return True
333
+
334
+
335
+ # --- finite / total / no_error_flag (in-process) --------------------------
336
+
337
+
338
+ def _check_finite_and_total(
339
+ document: dict,
340
+ functions_by_name: dict,
341
+ verification: "VerificationConfig",
342
+ package,
343
+ lattices: dict[str, "lattice.EntryLattice"],
344
+ points_limit: int | None,
345
+ ) -> list[PropertyOutcome]:
346
+ outcomes: list[PropertyOutcome] = []
347
+ for key, entry in document.get("entries", {}).items():
348
+ name = entry.get("name") or key
349
+ entry_lattice = lattices.get(key)
350
+ if functions_by_name.get(name) is None or entry_lattice is None:
351
+ continue
352
+ finite_failures: list[PointFailure] = []
353
+ total_failures: list[PointFailure] = []
354
+ checked = 0
355
+ for point in _points_for(entry_lattice, points_limit):
356
+ checked += 1
357
+ desc = _describe_point(point)
358
+ try:
359
+ _replay_setup(package, document, verification.state.setup)
360
+ result = _call_at_point(package, entry, point)
361
+ except Exception as exc: # noqa: BLE001 - recorded as a failure, not swallowed
362
+ total_failures.append(
363
+ PointFailure(desc, f"{type(exc).__name__}: {exc}")
364
+ )
365
+ continue
366
+ if not _is_finite(result):
367
+ finite_failures.append(
368
+ PointFailure(desc, f"non-finite result: {result!r}")
369
+ )
370
+ outcomes.append(PropertyOutcome("finite", key, checked, tuple(finite_failures)))
371
+ outcomes.append(PropertyOutcome("total", key, checked, tuple(total_failures)))
372
+ return outcomes
373
+
374
+
375
+ def _check_no_error_flag(
376
+ document: dict,
377
+ functions_by_name: dict,
378
+ verification: "VerificationConfig",
379
+ package,
380
+ lattices: dict[str, "lattice.EntryLattice"],
381
+ points_limit: int | None,
382
+ ) -> list[PropertyOutcome]:
383
+ error_flag = verification.state.error_flag
384
+ if not error_flag:
385
+ return []
386
+ error_flag_entry_key = None
387
+ for key, entry in document.get("entries", {}).items():
388
+ if (entry.get("name") or key) == error_flag:
389
+ error_flag_entry_key = key
390
+ break
391
+ if error_flag_entry_key is None:
392
+ raise KeyError(
393
+ f"state.error_flag names {error_flag!r}, which has no golden.json entry"
394
+ )
395
+ error_flag_entry = document["entries"][error_flag_entry_key]
396
+
397
+ outcomes: list[PropertyOutcome] = []
398
+ for key, entry in document.get("entries", {}).items():
399
+ name = entry.get("name") or key
400
+ if name == error_flag:
401
+ continue # the accessor is not checked against itself
402
+ entry_lattice = lattices.get(key)
403
+ if functions_by_name.get(name) is None or entry_lattice is None:
404
+ continue
405
+ failures: list[PointFailure] = []
406
+ checked = 0
407
+ for point in _points_for(entry_lattice, points_limit):
408
+ checked += 1
409
+ desc = _describe_point(point)
410
+ try:
411
+ _replay_setup(package, document, verification.state.setup)
412
+ _call_at_point(package, entry, point)
413
+ flag_value, _ = golden.invoke(error_flag_entry, package)
414
+ except Exception as exc: # noqa: BLE001
415
+ failures.append(PointFailure(desc, f"{type(exc).__name__}: {exc}"))
416
+ continue
417
+ if flag_value:
418
+ failures.append(
419
+ PointFailure(
420
+ desc,
421
+ f"{error_flag}() reported {flag_value!r} (truthy/nonzero) "
422
+ f"after calling {name}({point.arguments!r})",
423
+ )
424
+ )
425
+ outcomes.append(PropertyOutcome("no_error_flag", key, checked, tuple(failures)))
426
+ return outcomes
427
+
428
+
429
+ # --- the fresh-process worker (idempotent / order_independent) -----------
430
+
431
+
432
+ def _setup_calls(document: dict, verification: "VerificationConfig") -> list[dict]:
433
+ calls = []
434
+ for name in verification.state.setup:
435
+ entry = document.get("entries", {}).get(name)
436
+ if entry is None:
437
+ raise KeyError(
438
+ f"state.setup names {name!r}, which has no golden.json entry "
439
+ "to replay it with"
440
+ )
441
+ calls.append({"name": name, "arguments": list(entry["arguments"])})
442
+ return calls
443
+
444
+
445
+ def _run_worker_sequence(
446
+ calls: list[dict],
447
+ observe: list[int],
448
+ target: SubprocessTarget,
449
+ *,
450
+ python_executable: str | None = None,
451
+ timeout: float = 120.0,
452
+ ) -> dict:
453
+ """Run `calls` (a scripted call sequence) in a fresh subprocess,
454
+ reporting the results of the calls at `observe` positions. See the
455
+ module docstring's "The subprocess worker" for the wire format."""
456
+ python_executable = python_executable or sys.executable
457
+ with tempfile.TemporaryDirectory() as tmp:
458
+ spec_path = Path(tmp) / "spec.json"
459
+ spec_path.write_text(
460
+ json.dumps(
461
+ {
462
+ "sys_path": list(target.sys_path),
463
+ "module": target.module_name,
464
+ "attr_path": list(target.attr_path),
465
+ "calls": calls,
466
+ "observe": observe,
467
+ }
468
+ )
469
+ )
470
+ env = {**os.environ, **buildinfo.pinned_environment()}
471
+ try:
472
+ result = subprocess.run(
473
+ [python_executable, "-m", "nativegate.structural_invariants", "worker", str(spec_path)],
474
+ capture_output=True,
475
+ text=True,
476
+ env=env,
477
+ timeout=timeout,
478
+ check=False,
479
+ )
480
+ except subprocess.TimeoutExpired as exc:
481
+ raise WorkerError(
482
+ f"structural invariants worker timed out after {timeout}s"
483
+ ) from exc
484
+
485
+ if result.returncode not in (0, 1):
486
+ raise WorkerError(
487
+ f"structural invariants worker crashed (exit {result.returncode}):\n"
488
+ f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
489
+ )
490
+ stdout = result.stdout.strip()
491
+ if not stdout:
492
+ raise WorkerError(
493
+ f"structural invariants worker produced no output (exit "
494
+ f"{result.returncode}):\nstderr:\n{result.stderr}"
495
+ )
496
+ try:
497
+ return json.loads(stdout.splitlines()[-1])
498
+ except json.JSONDecodeError as exc:
499
+ raise WorkerError(
500
+ f"structural invariants worker produced unparseable output: {stdout!r}"
501
+ ) from exc
502
+
503
+
504
+ def _values_equal(a, b) -> bool:
505
+ """Bitwise-equal for floats (via `wire.pack_float`, per spec section
506
+ 3.2/4 rule 1 -- "bits, not decimals"), recursively for lists, exact `==`
507
+ otherwise (int/bool/str/None -- there is no bit channel for these, but
508
+ the same no-tolerance principle applies: exact equality or nothing)."""
509
+ if isinstance(a, float) or isinstance(b, float):
510
+ if not (isinstance(a, float) and isinstance(b, float)):
511
+ return a == b
512
+ return wire.pack_float(a) == wire.pack_float(b)
513
+ if isinstance(a, list) and isinstance(b, list):
514
+ return len(a) == len(b) and all(_values_equal(x, y) for x, y in zip(a, b))
515
+ return a == b
516
+
517
+
518
+ def _check_idempotent(
519
+ document: dict,
520
+ verification: "VerificationConfig",
521
+ target: SubprocessTarget,
522
+ lattices: dict[str, "lattice.EntryLattice"],
523
+ points_limit: int | None,
524
+ python_executable: str | None,
525
+ ) -> list[PropertyOutcome]:
526
+ mutating = set(verification.state.mutating)
527
+ # `error_flag` is excluded from `idempotent` the same way `mutating`
528
+ # routines are -- see the module docstring's "error_flag exemption".
529
+ if verification.state.error_flag:
530
+ mutating = mutating | {verification.state.error_flag}
531
+ setup_calls = _setup_calls(document, verification)
532
+ base_index = len(setup_calls)
533
+ outcomes: list[PropertyOutcome] = []
534
+
535
+ for key, entry in document.get("entries", {}).items():
536
+ name = entry.get("name") or key
537
+ entry_lattice = lattices.get(key)
538
+ if name in mutating or entry_lattice is None:
539
+ continue
540
+ failures: list[PointFailure] = []
541
+ checked = 0
542
+ for point in _points_for(entry_lattice, points_limit):
543
+ checked += 1
544
+ desc = _describe_point(point)
545
+ calls = setup_calls + [
546
+ {"name": name, "arguments": list(point.arguments)},
547
+ {"name": name, "arguments": list(point.arguments)},
548
+ ]
549
+ try:
550
+ payload = _run_worker_sequence(
551
+ calls, [base_index, base_index + 1], target,
552
+ python_executable=python_executable,
553
+ )
554
+ except WorkerError as exc:
555
+ failures.append(PointFailure(desc, str(exc)))
556
+ continue
557
+ if not payload.get("ok"):
558
+ failures.append(PointFailure(desc, payload.get("error", "worker failed")))
559
+ continue
560
+ values = {o["index"]: o["value"] for o in payload["observations"]}
561
+ first, second = values.get(base_index), values.get(base_index + 1)
562
+ if not _values_equal(first, second):
563
+ failures.append(
564
+ PointFailure(
565
+ desc,
566
+ f"{name}({point.arguments!r}) called twice in a row, in a "
567
+ f"fresh process, returned different bits: first={first!r} "
568
+ f"second={second!r}",
569
+ )
570
+ )
571
+ outcomes.append(PropertyOutcome("idempotent", key, checked, tuple(failures)))
572
+ return outcomes
573
+
574
+
575
+ def _check_order_independent(
576
+ document: dict,
577
+ verification: "VerificationConfig",
578
+ target: SubprocessTarget,
579
+ lattices: dict[str, "lattice.EntryLattice"],
580
+ points_limit: int | None,
581
+ python_executable: str | None,
582
+ ) -> list[PropertyOutcome]:
583
+ mutating = set(verification.state.mutating)
584
+ # `error_flag` is excluded from being chosen as the interposed `g`
585
+ # routine -- but NOT from being the `f` under test -- the same
586
+ # asymmetric treatment described in the module docstring's "error_flag
587
+ # exemption" (a clear-on-read accessor is a bad *perturbation*, since
588
+ # calling it as `g` would itself clear state as a side effect
589
+ # unrelated to whatever `f` is being tested; there is nothing
590
+ # suspect about checking `f`'s own order-independence).
591
+ g_excluded = mutating | ({verification.state.error_flag} if verification.state.error_flag else set())
592
+ setup_calls = _setup_calls(document, verification)
593
+ base_index = len(setup_calls)
594
+
595
+ entries = document.get("entries", {})
596
+ non_mutating: list[tuple[str, dict]] = [
597
+ (entry.get("name") or key, entry)
598
+ for key, entry in entries.items()
599
+ if (entry.get("name") or key) not in g_excluded
600
+ ]
601
+
602
+ outcomes: list[PropertyOutcome] = []
603
+ for key, entry in entries.items():
604
+ f_name = entry.get("name") or key
605
+ entry_lattice = lattices.get(key)
606
+ if f_name in mutating or entry_lattice is None:
607
+ continue
608
+ failures: list[PointFailure] = []
609
+ checked = 0
610
+ for point in _points_for(entry_lattice, points_limit):
611
+ checked += 1
612
+ desc = _describe_point(point)
613
+
614
+ baseline_calls = setup_calls + [
615
+ {"name": f_name, "arguments": list(point.arguments)},
616
+ {"name": f_name, "arguments": list(point.arguments)},
617
+ ]
618
+ try:
619
+ baseline_payload = _run_worker_sequence(
620
+ baseline_calls, [base_index + 1], target,
621
+ python_executable=python_executable,
622
+ )
623
+ except WorkerError as exc:
624
+ failures.append(PointFailure(desc, f"baseline: {exc}"))
625
+ continue
626
+ if not baseline_payload.get("ok"):
627
+ failures.append(
628
+ PointFailure(desc, f"baseline: {baseline_payload.get('error')}")
629
+ )
630
+ continue
631
+ baseline_values = {o["index"]: o["value"] for o in baseline_payload["observations"]}
632
+ baseline_second = baseline_values.get(base_index + 1)
633
+
634
+ for g_name, g_entry in non_mutating:
635
+ g_arguments = list(g_entry["arguments"])
636
+ perturbed_calls = setup_calls + [
637
+ {"name": f_name, "arguments": list(point.arguments)},
638
+ {"name": g_name, "arguments": g_arguments},
639
+ {"name": f_name, "arguments": list(point.arguments)},
640
+ ]
641
+ second_f_index = base_index + 2
642
+ try:
643
+ payload = _run_worker_sequence(
644
+ perturbed_calls, [second_f_index], target,
645
+ python_executable=python_executable,
646
+ )
647
+ except WorkerError as exc:
648
+ failures.append(PointFailure(desc, f"g={g_name}: {exc}"))
649
+ continue
650
+ if not payload.get("ok"):
651
+ failures.append(
652
+ PointFailure(desc, f"g={g_name}: {payload.get('error')}")
653
+ )
654
+ continue
655
+ values = {o["index"]: o["value"] for o in payload["observations"]}
656
+ perturbed_second = values.get(second_f_index)
657
+ if not _values_equal(baseline_second, perturbed_second):
658
+ hint = _ORDER_INDEPENDENT_HINT.format(g=g_name, f=f_name)
659
+ failures.append(
660
+ PointFailure(
661
+ desc,
662
+ f"{f_name}({point.arguments!r}); {g_name}({g_arguments!r}); "
663
+ f"{f_name}({point.arguments!r}) != "
664
+ f"{f_name}({point.arguments!r}); {f_name}({point.arguments!r}) "
665
+ f"(baseline second call={baseline_second!r}, "
666
+ f"perturbed second call={perturbed_second!r}) -- {hint}",
667
+ )
668
+ )
669
+ outcomes.append(PropertyOutcome("order_independent", key, checked, tuple(failures)))
670
+ return outcomes
671
+
672
+
673
+ # --- the public entry point ------------------------------------------------
674
+
675
+
676
+ def run_structural_invariants(
677
+ module: "ModuleIR",
678
+ document: dict,
679
+ verification: "VerificationConfig",
680
+ package,
681
+ target: SubprocessTarget,
682
+ *,
683
+ lattices: dict[str, "lattice.EntryLattice"] | None = None,
684
+ n: int = lattice.DEFAULT_SWEEP_POINTS,
685
+ points_limit: int | None = None,
686
+ python_executable: str | None = None,
687
+ ) -> StructuralInvariantResults:
688
+ """Every structural property (spec section 3.2's table) for every
689
+ golden.json entry whose function is in `module` and has a lattice.
690
+
691
+ `package` is the already-imported extension, used for the in-process
692
+ checks (`finite`/`total`/`no_error_flag`); `target` tells the fresh-
693
+ process worker how to import the (same, or equivalent) module for
694
+ `idempotent`/`order_independent` -- kept separate from `package`
695
+ because the in-process checks need a live object and the subprocess
696
+ checks need an *importable name*, and the two are not always the same
697
+ thing (a package already imported under a temporary name, e.g.).
698
+
699
+ `points_limit` caps the number of lattice points evaluated per
700
+ property per function -- primarily for `idempotent`/`order_independent`,
701
+ whose cost is one-or-more fresh subprocess launches *per point*; pass
702
+ `None` (the default) for full spec-mandated coverage ("at every lattice
703
+ point"), or a small integer to keep test runtime bounded.
704
+ """
705
+ functions_by_name = {fn.name: fn for fn in module.functions}
706
+ if lattices is None:
707
+ lattices = build_lattices(document, functions_by_name, verification, n=n)
708
+
709
+ outcomes: list[PropertyOutcome] = []
710
+ outcomes += _check_finite_and_total(
711
+ document, functions_by_name, verification, package, lattices, points_limit
712
+ )
713
+ outcomes += _check_no_error_flag(
714
+ document, functions_by_name, verification, package, lattices, points_limit
715
+ )
716
+ outcomes += _check_idempotent(
717
+ document, verification, target, lattices, points_limit, python_executable
718
+ )
719
+ outcomes += _check_order_independent(
720
+ document, verification, target, lattices, points_limit, python_executable
721
+ )
722
+ return StructuralInvariantResults(tuple(outcomes))
723
+
724
+
725
+ # --- the worker process entry point ----------------------------------------
726
+
727
+
728
+ def _worker_main(argv: list[str]) -> int:
729
+ if len(argv) < 2 or argv[0] != "worker":
730
+ sys.stderr.write(
731
+ "usage: python -m nativegate.structural_invariants worker <spec.json>\n"
732
+ )
733
+ return 2
734
+
735
+ spec = json.loads(Path(argv[1]).read_text())
736
+ for path in spec.get("sys_path", []):
737
+ if path not in sys.path:
738
+ sys.path.insert(0, path)
739
+
740
+ try:
741
+ package = importlib.import_module(spec["module"])
742
+ for attr in spec.get("attr_path", []):
743
+ package = getattr(package, attr)
744
+ observe = set(spec.get("observe", []))
745
+ observations = []
746
+ for index, call in enumerate(spec["calls"]):
747
+ arguments = [golden._materialise(a, package) for a in call["arguments"]]
748
+ fn = getattr(package, call["name"])
749
+ result = golden._jsonable(fn(*arguments))
750
+ if index in observe:
751
+ observations.append({"index": index, "value": result})
752
+ payload = {"ok": True, "observations": observations}
753
+ except Exception as exc: # noqa: BLE001 - reported to the parent, not raised here
754
+ payload = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
755
+
756
+ sys.stdout.write(json.dumps(payload) + "\n")
757
+ sys.stdout.flush()
758
+ return 0 if payload["ok"] else 1
759
+
760
+
761
+ if __name__ == "__main__":
762
+ raise SystemExit(_worker_main(sys.argv[1:]))