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
nativegate/config.py ADDED
@@ -0,0 +1,991 @@
1
+ """Loader for a service's nativegate.yaml (design.md section 9)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import warnings
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Union
10
+
11
+ import yaml
12
+
13
+ if TYPE_CHECKING:
14
+ from .ir import ModuleIR
15
+
16
+ CONFIG_FILENAME = "nativegate.yaml"
17
+
18
+ SUPPORTED_LANGUAGES = ("cpp", "fortran")
19
+
20
+ # Closed vocabulary of declared invariant properties (design-verification-layers.md
21
+ # section 3.3). This set is exhaustive and deliberately NOT extensible via an
22
+ # eval'd expression string or any other escape hatch: "if a property does not
23
+ # fit the vocabulary, the vocabulary grows in a reviewed commit" (spec verbatim).
24
+ # Never add a generic/expression form here — that is a spec change, not a task
25
+ # decision, and the spec is emphatic that this must never happen.
26
+ INVARIANT_VOCABULARY = (
27
+ "bounds",
28
+ "monotone",
29
+ "sum_to_one",
30
+ "symmetric_in",
31
+ "scales_linearly_in",
32
+ )
33
+
34
+ MONOTONE_DIRECTIONS = ("nondecreasing", "nonincreasing")
35
+
36
+
37
+ class ConfigError(ValueError):
38
+ """nativegate.yaml is missing something nativegate refuses to guess."""
39
+
40
+
41
+ class ExposeWarning(UserWarning):
42
+ """An empty `expose:` block was read as "bind everything" without an opt-in."""
43
+
44
+
45
+ @dataclass
46
+ class ExposeConfig:
47
+ classes: list[str] = field(default_factory=list)
48
+ functions: list[str] = field(default_factory=list)
49
+ # Explicit opt-in to "bind the whole public surface": `expose: all` or
50
+ # `expose: {all: true}` in nativegate.yaml. None means "not stated" — which
51
+ # is still permissive for direct construction (the parsers rely on that,
52
+ # falling back to `[[nativegate::expose]]` annotations), but ServiceConfig.load
53
+ # warns about it so the intent isn't left implicit in a yaml file.
54
+ all: bool | None = None
55
+
56
+ def is_exposed(self, name: str) -> bool:
57
+ if self.all:
58
+ return True
59
+ if not self.classes and not self.functions:
60
+ # Nothing named: permissive unless the user explicitly said `all: false`.
61
+ return self.all is not False
62
+ return name in self.classes or name in self.functions
63
+
64
+ @property
65
+ def is_empty(self) -> bool:
66
+ return not self.classes and not self.functions
67
+
68
+
69
+ @dataclass
70
+ class ClangConfig:
71
+ """Compiler flags the C++ AST parser needs to read this service's headers.
72
+
73
+ A real front end has to be told what a compiler would be told: where the
74
+ other headers live, which macros the build defines, which standard the
75
+ code is written against. Getting these wrong doesn't fail loudly — clang
76
+ recovers from an unknown type by pretending it was `int` — so nativegate
77
+ reports every parse error it sees rather than binding the wreckage.
78
+ """
79
+
80
+ std: str = "c++17"
81
+ include_paths: list[str] = field(default_factory=list)
82
+ defines: list[str] = field(default_factory=list)
83
+ extra_args: list[str] = field(default_factory=list)
84
+ # Names of extern "C" functions whose bare `T*` arguments are SCALARS
85
+ # passed by reference — the Fortran-linkage convention. Opt-in per
86
+ # function, never inferred: `double* x` is also how C passes an array
87
+ # whose length travels through COMMON or a PARAMETER, and binding an
88
+ # array as one scalar hands the callee a pointer it reads past. FLASH2's
89
+ # XLIQ(NCMAX) looks exactly like PVTRS's scalar `p` in a C prototype —
90
+ # only the person who read the Fortran knows which is which, so they say
91
+ # so here. "*" asserts it for every extern "C" function in the service.
92
+ scalar_ref_functions: list[str] = field(default_factory=list)
93
+
94
+
95
+ @dataclass
96
+ class ApiConfig:
97
+ """How the generated HTTP API authenticates callers.
98
+
99
+ Only the *mode* lives here. Keys never do: `nativegate.yaml` is committed,
100
+ and a credential in a committed file is a credential in every clone and
101
+ every image layer. The generated middleware reads keys from
102
+ `NATIVEGATE_API_KEYS` at startup, and refuses to start if the mode requires
103
+ them and none are present — failing closed, because a service that
104
+ silently drops authentication is discovered by an attacker rather than by
105
+ whoever deployed it.
106
+ """
107
+
108
+ # "none" (open — logged loudly at startup) or "api_key".
109
+ auth: str = "none"
110
+
111
+
112
+ @dataclass
113
+ class BoundsProperty:
114
+ """`bounds: {min: ..., max: ...}` — at least one of min/max is required."""
115
+
116
+ min: float | None = None
117
+ max: float | None = None
118
+
119
+
120
+ @dataclass
121
+ class MonotoneProperty:
122
+ """`monotone: {in: <param>, direction: nondecreasing|nonincreasing}`.
123
+
124
+ Compared with raw `>=`/`<=` at run time, no tolerance (spec §3.4) — this
125
+ dataclass carries no tolerance field, and none may be added.
126
+ """
127
+
128
+ parameter: str
129
+ direction: str
130
+
131
+
132
+ @dataclass
133
+ class SumToOneProperty:
134
+ """`sum_to_one: [a, b, c]` with an optional sibling `tolerance:`."""
135
+
136
+ fields: list[str] = field(default_factory=list)
137
+ tolerance: float = 0.0
138
+
139
+
140
+ @dataclass
141
+ class SymmetricInProperty:
142
+ """`symmetric_in: [a, b]` — the result is unchanged under permuting these
143
+ named parameters."""
144
+
145
+ parameters: list[str] = field(default_factory=list)
146
+
147
+
148
+ @dataclass
149
+ class ScalesLinearlyInProperty:
150
+ """`scales_linearly_in: {in: <param>}` — the result scales linearly with
151
+ the named parameter, all others held fixed."""
152
+
153
+ parameter: str
154
+
155
+
156
+ # The typed union every declared property parses into. Runners (T9/T10/T11)
157
+ # switch on the concrete type rather than on a string tag, so a new vocabulary
158
+ # word cannot be half-added (a string literal with no matching dataclass).
159
+ InvariantProperty = Union[
160
+ BoundsProperty,
161
+ MonotoneProperty,
162
+ SumToOneProperty,
163
+ SymmetricInProperty,
164
+ ScalesLinearlyInProperty,
165
+ ]
166
+
167
+
168
+ @dataclass
169
+ class StateConfig:
170
+ """`state:` — declared mutators (design-verification-layers.md §3.5).
171
+
172
+ Structural purity checks (`idempotent`, `order_independent`) apply to
173
+ every entry point NOT listed in `mutating`; applying them universally
174
+ would condemn `petro_api`'s own contract (`pvt_set_fluid` mutates COMMON
175
+ state by design). Declaring this here is what makes an *undeclared*
176
+ mutator mechanically detectable instead of a matter of trust.
177
+
178
+ Design decision (not literal spec text, design-verification-layers.md
179
+ §3.5 does not say this explicitly -- flagged here rather than decided
180
+ silently): the declared `error_flag` accessor is ALSO implicitly
181
+ excluded from `idempotent` and from being chosen as the interposed `g`
182
+ routine in `order_independent`, the same way `mutating` routines are.
183
+ `error_flag` names a clear-on-read accessor (e.g. petro_api's
184
+ `last_error`/`PVTERR`, which returns the stored error code and then
185
+ resets it) -- calling it twice legitimately returns different bits on
186
+ the second call, which would make `idempotent` fail not because of a
187
+ bug but because clear-on-read is not idempotent by construction. This
188
+ is the same "the check would condemn the API's own contract" reasoning
189
+ already applied to `mutating` above, just for a different kind of
190
+ intentional statefulness. See `structural_invariants.py`'s exemption
191
+ logic for where this is enforced.
192
+ """
193
+
194
+ setup: list[str] = field(default_factory=list)
195
+ mutating: list[str] = field(default_factory=list)
196
+ error_flag: str | None = None
197
+
198
+
199
+ @dataclass
200
+ class RangeDeclaration:
201
+ """`ranges: {pressure: [lo, hi]}` — one entry, already validated (lo <
202
+ hi, both finite)."""
203
+
204
+ lo: float
205
+ hi: float
206
+
207
+ def __iter__(self):
208
+ return iter((self.lo, self.hi))
209
+
210
+
211
+ @dataclass
212
+ class ScatterDeclaration:
213
+ """`lattice.scatter: {seed: ..., count: ...}` (spec §3.4 item 3).
214
+
215
+ `seed` is required once `count` > 0 -- `lattice.build_entry_lattice`
216
+ raises `ValueError` for a positive count with no seed, and this loader
217
+ catches the same mistake earlier, at config-load time, with a message
218
+ that names the file.
219
+ """
220
+
221
+ seed: int | None = None
222
+ count: int = 0
223
+
224
+
225
+ @dataclass
226
+ class LatticeConfig:
227
+ """`lattice:` — declares the YAML surface for `lattice.py`'s (T9)
228
+ sweep point count, scatter seed/count, and per-function corners (spec
229
+ §3.4 items 1-3).
230
+
231
+ Design decision (not literal spec text, flagged here rather than
232
+ decided silently): `lattice.py`'s `build_entry_lattice`/`scatter`
233
+ already implement corners and scatter sampling and accept them as bare
234
+ keyword arguments, but until this task there was no `nativegate.yaml`
235
+ surface to *declare* them -- only explicit Python callers (tests) could
236
+ populate them. This dataclass is that surface. `corners` is keyed by
237
+ exposed function name, each value a list of full positional argument
238
+ tuples passed through to `lattice.build_entry_lattice`'s `corners=`
239
+ verbatim (see that function's docstring: "passed through completely
240
+ unmodified... this function does not validate their arity or clamp them
241
+ to any range") -- this loader does not validate arity either, only that
242
+ the function name is exposed.
243
+ """
244
+
245
+ n: int | None = None
246
+ scatter: ScatterDeclaration = field(default_factory=ScatterDeclaration)
247
+ corners: dict[str, list[tuple]] = field(default_factory=dict)
248
+
249
+ @property
250
+ def is_empty(self) -> bool:
251
+ return self.n is None and self.scatter.count == 0 and self.scatter.seed is None and not self.corners
252
+
253
+
254
+ @dataclass
255
+ class VerificationConfig:
256
+ """Parsed `state:` / `invariants:` / `ranges:` / `lattice:` blocks
257
+ (design-verification-layers.md §3.2-3.5).
258
+
259
+ This is the single place declared invariant semantics live; T9/T10/T11's
260
+ lattice and runners consume these typed objects rather than re-parsing
261
+ YAML themselves.
262
+ """
263
+
264
+ state: StateConfig = field(default_factory=StateConfig)
265
+ invariants: dict[str, list[InvariantProperty]] = field(default_factory=dict)
266
+ ranges: dict[str, RangeDeclaration] = field(default_factory=dict)
267
+ lattice: LatticeConfig = field(default_factory=LatticeConfig)
268
+
269
+ @property
270
+ def is_empty(self) -> bool:
271
+ return (
272
+ not self.state.setup
273
+ and not self.state.mutating
274
+ and self.state.error_flag is None
275
+ and not self.invariants
276
+ and not self.ranges
277
+ and self.lattice.is_empty
278
+ )
279
+
280
+ def has_range(self, parameter: str) -> bool:
281
+ """False for a swept parameter with NO declared range.
282
+
283
+ Spec §3.4: "there is no default range — a swept parameter with no
284
+ declared range is an error, not a guess, recorded under `uncovered`."
285
+ That `uncovered` bookkeeping is T12's job; this method exists so a
286
+ later runner can distinguish "absent" cleanly, which is this task's
287
+ job (parse-time, `ranges:` must not silently default to anything).
288
+ """
289
+ return parameter in self.ranges
290
+
291
+ def validate_against_ir(self, module: "ModuleIR") -> None:
292
+ """Second-phase validation once a parsed signature is available.
293
+
294
+ `ServiceConfig.load` validates everything checkable from the YAML
295
+ and the `expose:` block alone (closed vocabulary, enum values,
296
+ required sub-keys, functions named in `expose:`). Whether
297
+ `monotone.in` actually names a *parameter* of that function needs the
298
+ parsed signature (`ModuleIR`), which `ServiceConfig.load` does not
299
+ build (it would give every config load a parser dependency it
300
+ doesn't otherwise have). Callers that have already parsed the
301
+ service's IR (the CLI, T9/T10/T11 runners, or a test that parses the
302
+ real source with `parsers.fortran`) call this explicitly.
303
+ """
304
+ functions_by_name = {fn.name: fn for fn in module.functions}
305
+ for fn_name, properties in self.invariants.items():
306
+ fn = functions_by_name.get(fn_name)
307
+ if fn is None:
308
+ continue # not this module; another IR may cover it
309
+ param_names = {p.name for p in fn.parameters}
310
+ for prop in properties:
311
+ if isinstance(prop, MonotoneProperty) and prop.parameter not in param_names:
312
+ raise ConfigError(
313
+ f"invariants.{fn_name}: monotone.in '{prop.parameter}' is not "
314
+ f"a parameter of '{fn_name}' (parameters: "
315
+ f"{', '.join(sorted(param_names)) or '(none)'})."
316
+ )
317
+ if isinstance(prop, ScalesLinearlyInProperty) and prop.parameter not in param_names:
318
+ raise ConfigError(
319
+ f"invariants.{fn_name}: scales_linearly_in.in "
320
+ f"'{prop.parameter}' is not a parameter of '{fn_name}' "
321
+ f"(parameters: {', '.join(sorted(param_names)) or '(none)'})."
322
+ )
323
+ if isinstance(prop, SymmetricInProperty):
324
+ unknown = [p for p in prop.parameters if p not in param_names]
325
+ if unknown:
326
+ raise ConfigError(
327
+ f"invariants.{fn_name}: symmetric_in names "
328
+ f"{unknown!r}, not parameters of '{fn_name}' "
329
+ f"(parameters: {', '.join(sorted(param_names)) or '(none)'})."
330
+ )
331
+
332
+
333
+ def _load_verification(
334
+ data, config_path: Path, known_functions: set[str] | None
335
+ ) -> VerificationConfig:
336
+ """Read `state:`, `invariants:`, `ranges:` — all three optional blocks.
337
+
338
+ `known_functions` is the service's `expose.functions` list: everything
339
+ `state:`/`invariants:` may name. Parameter-level checks (`monotone.in`)
340
+ are NOT done here — see `VerificationConfig.validate_against_ir`.
341
+ """
342
+ state = _load_state(data.get("state"), config_path, known_functions)
343
+ invariants = _load_invariants(data.get("invariants"), config_path, known_functions)
344
+ ranges = _load_ranges(data.get("ranges"), config_path)
345
+ lattice_config = _load_lattice(data.get("lattice"), config_path, known_functions)
346
+ return VerificationConfig(
347
+ state=state, invariants=invariants, ranges=ranges, lattice=lattice_config
348
+ )
349
+
350
+
351
+ def _load_state(
352
+ state_data, config_path: Path, known_functions: set[str] | None
353
+ ) -> StateConfig:
354
+ state_data = state_data or {}
355
+ if not isinstance(state_data, dict):
356
+ raise ConfigError(
357
+ f"{config_path}: `state:` must be a block, got {type(state_data).__name__}."
358
+ )
359
+ _reject_unknown_keys(state_data, {"setup", "mutating", "error_flag"}, "state:", config_path)
360
+
361
+ setup = _require_known_functions(
362
+ list(state_data.get("setup") or []), "state.setup", config_path, known_functions
363
+ )
364
+ mutating = _require_known_functions(
365
+ list(state_data.get("mutating") or []), "state.mutating", config_path, known_functions
366
+ )
367
+ error_flag = state_data.get("error_flag")
368
+ if error_flag is not None:
369
+ error_flag = str(error_flag)
370
+ _require_known_functions([error_flag], "state.error_flag", config_path, known_functions)
371
+ return StateConfig(setup=setup, mutating=mutating, error_flag=error_flag)
372
+
373
+
374
+ def _reject_unknown_keys(data: dict, allowed: set[str], where: str, config_path: Path) -> None:
375
+ """Closed-key check shared by every `{where}:` block in this module.
376
+
377
+ Every declared block (`state:`, `bounds`, `monotone`,
378
+ `scales_linearly_in`, `lattice:`, `lattice.scatter:`, ...) rejects any
379
+ key outside its own fixed set the same way -- factored here so the
380
+ wording only needs to be right, and only needs updating, in one place.
381
+ """
382
+ unknown_keys = set(data) - allowed
383
+ if unknown_keys:
384
+ raise ConfigError(
385
+ f"{config_path}: `{where}` has unrecognised key(s) "
386
+ f"{sorted(unknown_keys)}. Only {sorted(allowed)} are understood."
387
+ )
388
+
389
+
390
+ def _require_known_functions(
391
+ names: list[str], where: str, config_path: Path, known_functions: set[str] | None
392
+ ) -> list[str]:
393
+ if known_functions is None:
394
+ return list(names)
395
+ for name in names:
396
+ if name not in known_functions:
397
+ raise ConfigError(
398
+ f"{config_path}: `{where}` names '{name}', which is not in "
399
+ f"`expose.functions:`. Only exposed functions may be "
400
+ "declared here."
401
+ )
402
+ return list(names)
403
+
404
+
405
+ def _load_invariants(
406
+ invariants_data, config_path: Path, known_functions: set[str] | None
407
+ ) -> dict[str, list[InvariantProperty]]:
408
+ invariants_data = invariants_data or {}
409
+ if not isinstance(invariants_data, dict):
410
+ raise ConfigError(
411
+ f"{config_path}: `invariants:` must be a mapping of function name "
412
+ f"to a list of properties, got {type(invariants_data).__name__}."
413
+ )
414
+
415
+ result: dict[str, list[InvariantProperty]] = {}
416
+ for fn_name, entries in invariants_data.items():
417
+ if known_functions is not None and fn_name not in known_functions:
418
+ raise ConfigError(
419
+ f"{config_path}: `invariants.{fn_name}` names a function not "
420
+ "in `expose.functions:`. Only exposed functions may carry "
421
+ "declared invariants."
422
+ )
423
+ if not isinstance(entries, list) or not entries:
424
+ raise ConfigError(
425
+ f"{config_path}: `invariants.{fn_name}` must be a non-empty "
426
+ f"list of property declarations, got {entries!r}."
427
+ )
428
+ properties: list[InvariantProperty] = []
429
+ for entry in entries:
430
+ properties.append(
431
+ _load_property(entry, fn_name, config_path)
432
+ )
433
+ result[fn_name] = properties
434
+ return result
435
+
436
+
437
+ def _load_property(entry, fn_name: str, config_path: Path) -> InvariantProperty:
438
+ """Parse one `- word: {...}` entry against the CLOSED vocabulary.
439
+
440
+ No branch here ever evaluates a string as code, and none may be added:
441
+ design-verification-layers.md §3.3 is emphatic that the vocabulary is
442
+ fixed and any pressure to add an eval-based escape hatch is a spec
443
+ change, not a task decision.
444
+ """
445
+ if not isinstance(entry, dict) or not entry:
446
+ raise ConfigError(
447
+ f"{config_path}: invariants.{fn_name} entry {entry!r} must be a "
448
+ "mapping naming exactly one property "
449
+ f"({', '.join(INVARIANT_VOCABULARY)})."
450
+ )
451
+ vocabulary_keys = [k for k in entry if k in INVARIANT_VOCABULARY]
452
+ if len(vocabulary_keys) != 1:
453
+ raise ConfigError(
454
+ f"{config_path}: invariants.{fn_name} entry {entry!r} must name "
455
+ f"exactly one property from the closed vocabulary "
456
+ f"({', '.join(INVARIANT_VOCABULARY)}). No expression/eval form "
457
+ "exists; if this genuinely does not fit, that is a spec change, "
458
+ "not something to work around here."
459
+ )
460
+ word = vocabulary_keys[0]
461
+ payload = entry[word]
462
+ # `sum_to_one` is the one form with a sibling key in the same mapping
463
+ # entry (spec §3.2's example: `- sum_to_one: [sw, so, sg]` /
464
+ # ` tolerance: 1e-12`). Every other form must be the entry's only key.
465
+ extra_keys = set(entry) - {word} - ({"tolerance"} if word == "sum_to_one" else set())
466
+ if extra_keys:
467
+ raise ConfigError(
468
+ f"{config_path}: invariants.{fn_name} entry {entry!r} has "
469
+ f"unexpected key(s) {sorted(extra_keys)} alongside '{word}'."
470
+ )
471
+ where = f"invariants.{fn_name}.{word}"
472
+
473
+ if word == "bounds":
474
+ return _load_bounds(payload, where, config_path)
475
+ if word == "monotone":
476
+ return _load_monotone(payload, where, config_path)
477
+ if word == "sum_to_one":
478
+ return _load_sum_to_one(payload, entry, where, config_path)
479
+ if word == "symmetric_in":
480
+ return _load_symmetric_in(payload, where, config_path)
481
+ if word == "scales_linearly_in":
482
+ return _load_scales_linearly_in(payload, where, config_path)
483
+ raise AssertionError(f"unreachable: {word!r} passed the vocabulary check")
484
+
485
+
486
+ def _load_bounds(payload, where: str, config_path: Path) -> BoundsProperty:
487
+ if not isinstance(payload, dict):
488
+ raise ConfigError(f"{config_path}: `{where}` must be a mapping, got {payload!r}.")
489
+ minimum = payload.get("min")
490
+ maximum = payload.get("max")
491
+ if minimum is None and maximum is None:
492
+ raise ConfigError(
493
+ f"{config_path}: `{where}` needs at least one of `min`/`max`."
494
+ )
495
+ _reject_unknown_keys(payload, {"min", "max"}, where, config_path)
496
+ return BoundsProperty(
497
+ min=float(minimum) if minimum is not None else None,
498
+ max=float(maximum) if maximum is not None else None,
499
+ )
500
+
501
+
502
+ def _load_monotone(payload, where: str, config_path: Path) -> MonotoneProperty:
503
+ if not isinstance(payload, dict):
504
+ raise ConfigError(f"{config_path}: `{where}` must be a mapping, got {payload!r}.")
505
+ _reject_unknown_keys(payload, {"in", "direction"}, where, config_path)
506
+ parameter = payload.get("in")
507
+ direction = payload.get("direction")
508
+ if not parameter or not isinstance(parameter, str):
509
+ raise ConfigError(f"{config_path}: `{where}.in` must name a parameter (a string).")
510
+ if direction not in MONOTONE_DIRECTIONS:
511
+ raise ConfigError(
512
+ f"{config_path}: `{where}.direction` must be one of "
513
+ f"{MONOTONE_DIRECTIONS}, got {direction!r}."
514
+ )
515
+ return MonotoneProperty(parameter=parameter, direction=direction)
516
+
517
+
518
+ def _load_sum_to_one(payload, raw_entry: dict, where: str, config_path: Path) -> SumToOneProperty:
519
+ # `sum_to_one:` takes a bare list; an optional sibling `tolerance:` key
520
+ # sits next to it in the same mapping entry (per the spec's example:
521
+ # `- sum_to_one: [sw, so, sg]` / ` tolerance: 1e-12`), not nested inside
522
+ # the payload, so it is read from raw_entry rather than payload.
523
+ if not isinstance(payload, list) or not payload:
524
+ raise ConfigError(f"{config_path}: `{where}` must be a non-empty list of field names.")
525
+ fields = [str(f) for f in payload]
526
+ if len(set(fields)) != len(fields):
527
+ raise ConfigError(f"{config_path}: `{where}` lists a field more than once: {fields!r}.")
528
+ tolerance_raw = raw_entry.get("tolerance", 0.0)
529
+ try:
530
+ tolerance = float(tolerance_raw)
531
+ except (TypeError, ValueError):
532
+ raise ConfigError(f"{config_path}: `{where}` tolerance must be numeric, got {tolerance_raw!r}.")
533
+ if tolerance < 0 or not math.isfinite(tolerance):
534
+ raise ConfigError(f"{config_path}: `{where}` tolerance must be finite and >= 0.")
535
+ return SumToOneProperty(fields=fields, tolerance=tolerance)
536
+
537
+
538
+ def _load_symmetric_in(payload, where: str, config_path: Path) -> SymmetricInProperty:
539
+ if not isinstance(payload, list) or len(payload) < 2:
540
+ raise ConfigError(
541
+ f"{config_path}: `{where}` must be a list of at least two parameter names."
542
+ )
543
+ parameters = [str(p) for p in payload]
544
+ return SymmetricInProperty(parameters=parameters)
545
+
546
+
547
+ def _load_scales_linearly_in(payload, where: str, config_path: Path) -> ScalesLinearlyInProperty:
548
+ if isinstance(payload, str):
549
+ parameter = payload
550
+ elif isinstance(payload, dict):
551
+ _reject_unknown_keys(payload, {"in"}, where, config_path)
552
+ parameter = payload.get("in")
553
+ else:
554
+ raise ConfigError(
555
+ f"{config_path}: `{where}` must be a parameter name or `{{in: <param>}}`, "
556
+ f"got {payload!r}."
557
+ )
558
+ if not parameter or not isinstance(parameter, str):
559
+ raise ConfigError(f"{config_path}: `{where}.in` must name a parameter (a string).")
560
+ return ScalesLinearlyInProperty(parameter=parameter)
561
+
562
+
563
+ def _load_ranges(ranges_data, config_path: Path) -> dict[str, RangeDeclaration]:
564
+ ranges_data = ranges_data or {}
565
+ if not isinstance(ranges_data, dict):
566
+ raise ConfigError(
567
+ f"{config_path}: `ranges:` must be a mapping of parameter name to "
568
+ f"[lo, hi], got {type(ranges_data).__name__}."
569
+ )
570
+ result: dict[str, RangeDeclaration] = {}
571
+ for parameter, bounds in ranges_data.items():
572
+ if (
573
+ not isinstance(bounds, (list, tuple))
574
+ or len(bounds) != 2
575
+ ):
576
+ raise ConfigError(
577
+ f"{config_path}: `ranges.{parameter}` must be `[lo, hi]`, got {bounds!r}."
578
+ )
579
+ try:
580
+ lo, hi = float(bounds[0]), float(bounds[1])
581
+ except (TypeError, ValueError):
582
+ raise ConfigError(f"{config_path}: `ranges.{parameter}` values must be numeric, got {bounds!r}.")
583
+ if not math.isfinite(lo) or not math.isfinite(hi):
584
+ raise ConfigError(f"{config_path}: `ranges.{parameter}` must be finite, got {bounds!r}.")
585
+ if not lo < hi:
586
+ raise ConfigError(
587
+ f"{config_path}: `ranges.{parameter}` must have lo < hi, got [{lo}, {hi}]."
588
+ )
589
+ result[str(parameter)] = RangeDeclaration(lo=lo, hi=hi)
590
+ return result
591
+
592
+
593
+ def _load_lattice(
594
+ lattice_data, config_path: Path, known_functions: set[str] | None
595
+ ) -> LatticeConfig:
596
+ """Read the `lattice:` block: `n`, `scatter: {seed, count}`, `corners:`.
597
+
598
+ See `LatticeConfig`'s docstring for why this exists. Matches the rest of
599
+ this module's style: closed set of recognised keys, explicit errors
600
+ naming what's wrong, functions referenced in `corners:` must be exposed
601
+ (mirroring `_require_known_functions`'s treatment of `state:`/
602
+ `invariants:`).
603
+ """
604
+ lattice_data = lattice_data or {}
605
+ if not isinstance(lattice_data, dict):
606
+ raise ConfigError(
607
+ f"{config_path}: `lattice:` must be a block, got {type(lattice_data).__name__}."
608
+ )
609
+ _reject_unknown_keys(lattice_data, {"n", "scatter", "corners"}, "lattice:", config_path)
610
+
611
+ n = None
612
+ if "n" in lattice_data and lattice_data["n"] is not None:
613
+ n_raw = lattice_data["n"]
614
+ if isinstance(n_raw, bool) or not isinstance(n_raw, int):
615
+ raise ConfigError(f"{config_path}: `lattice.n` must be an integer, got {n_raw!r}.")
616
+ if n_raw < 2:
617
+ raise ConfigError(
618
+ f"{config_path}: `lattice.n` must be >= 2 (need both endpoints), got {n_raw!r}."
619
+ )
620
+ n = n_raw
621
+
622
+ scatter = _load_scatter(lattice_data.get("scatter"), config_path)
623
+ corners = _load_corners(lattice_data.get("corners"), config_path, known_functions)
624
+
625
+ return LatticeConfig(n=n, scatter=scatter, corners=corners)
626
+
627
+
628
+ def _load_scatter(scatter_data, config_path: Path) -> ScatterDeclaration:
629
+ scatter_data = scatter_data or {}
630
+ if not isinstance(scatter_data, dict):
631
+ raise ConfigError(
632
+ f"{config_path}: `lattice.scatter:` must be a block, got "
633
+ f"{type(scatter_data).__name__}."
634
+ )
635
+ _reject_unknown_keys(scatter_data, {"seed", "count"}, "lattice.scatter:", config_path)
636
+
637
+ seed_raw = scatter_data.get("seed")
638
+ seed = None
639
+ if seed_raw is not None:
640
+ if isinstance(seed_raw, bool) or not isinstance(seed_raw, int):
641
+ raise ConfigError(
642
+ f"{config_path}: `lattice.scatter.seed` must be an integer, got {seed_raw!r}."
643
+ )
644
+ seed = seed_raw
645
+
646
+ count_raw = scatter_data.get("count", 0)
647
+ if isinstance(count_raw, bool) or not isinstance(count_raw, int):
648
+ raise ConfigError(
649
+ f"{config_path}: `lattice.scatter.count` must be an integer, got {count_raw!r}."
650
+ )
651
+ if count_raw < 0:
652
+ raise ConfigError(
653
+ f"{config_path}: `lattice.scatter.count` must be >= 0, got {count_raw!r}."
654
+ )
655
+ if count_raw > 0 and seed is None:
656
+ raise ConfigError(
657
+ f"{config_path}: `lattice.scatter.count` is {count_raw!r} but "
658
+ "`lattice.scatter.seed` is missing. A positive count needs a "
659
+ "seed to be reproducible -- there is no default."
660
+ )
661
+ return ScatterDeclaration(seed=seed, count=count_raw)
662
+
663
+
664
+ def _load_corners(
665
+ corners_data, config_path: Path, known_functions: set[str] | None
666
+ ) -> dict[str, list[tuple]]:
667
+ corners_data = corners_data or {}
668
+ if not isinstance(corners_data, dict):
669
+ raise ConfigError(
670
+ f"{config_path}: `lattice.corners:` must be a mapping of function "
671
+ f"name to a list of argument tuples, got {type(corners_data).__name__}."
672
+ )
673
+ result: dict[str, list[tuple]] = {}
674
+ for fn_name, entries in corners_data.items():
675
+ _require_known_functions(
676
+ [fn_name], "lattice.corners", config_path, known_functions
677
+ )
678
+ if not isinstance(entries, list) or not entries:
679
+ raise ConfigError(
680
+ f"{config_path}: `lattice.corners.{fn_name}` must be a "
681
+ f"non-empty list of argument tuples, got {entries!r}."
682
+ )
683
+ tuples: list[tuple] = []
684
+ for entry in entries:
685
+ if not isinstance(entry, (list, tuple)):
686
+ raise ConfigError(
687
+ f"{config_path}: `lattice.corners.{fn_name}` entry {entry!r} "
688
+ "must be a list of positional argument values (one full "
689
+ "call's worth), passed through verbatim."
690
+ )
691
+ tuples.append(tuple(entry))
692
+ result[str(fn_name)] = tuples
693
+ return result
694
+
695
+
696
+ def _dump_property(prop: InvariantProperty) -> dict:
697
+ """Serialize one typed property back to its `nativegate.yaml` shape.
698
+
699
+ Inverse of `_load_property`, used by `ServiceConfig.save` so a
700
+ load/validate/save round trip is lossless.
701
+ """
702
+ if isinstance(prop, BoundsProperty):
703
+ payload = {}
704
+ if prop.min is not None:
705
+ payload["min"] = prop.min
706
+ if prop.max is not None:
707
+ payload["max"] = prop.max
708
+ return {"bounds": payload}
709
+ if isinstance(prop, MonotoneProperty):
710
+ return {"monotone": {"in": prop.parameter, "direction": prop.direction}}
711
+ if isinstance(prop, SumToOneProperty):
712
+ entry = {"sum_to_one": list(prop.fields)}
713
+ if prop.tolerance:
714
+ entry["tolerance"] = prop.tolerance
715
+ return entry
716
+ if isinstance(prop, SymmetricInProperty):
717
+ return {"symmetric_in": list(prop.parameters)}
718
+ if isinstance(prop, ScalesLinearlyInProperty):
719
+ return {"scales_linearly_in": {"in": prop.parameter}}
720
+ raise AssertionError(f"unreachable: no dump form for {prop!r}")
721
+
722
+
723
+ def _dump_verification(verification: VerificationConfig) -> dict:
724
+ data: dict = {}
725
+ state = verification.state
726
+ if state.setup or state.mutating or state.error_flag is not None:
727
+ state_block = {}
728
+ if state.setup:
729
+ state_block["setup"] = list(state.setup)
730
+ if state.mutating:
731
+ state_block["mutating"] = list(state.mutating)
732
+ if state.error_flag is not None:
733
+ state_block["error_flag"] = state.error_flag
734
+ data["state"] = state_block
735
+ if verification.invariants:
736
+ data["invariants"] = {
737
+ fn_name: [_dump_property(p) for p in properties]
738
+ for fn_name, properties in verification.invariants.items()
739
+ }
740
+ if verification.ranges:
741
+ data["ranges"] = {
742
+ name: [rng.lo, rng.hi] for name, rng in verification.ranges.items()
743
+ }
744
+ lattice_config = verification.lattice
745
+ if not lattice_config.is_empty:
746
+ lattice_block: dict = {}
747
+ if lattice_config.n is not None:
748
+ lattice_block["n"] = lattice_config.n
749
+ if lattice_config.scatter.count or lattice_config.scatter.seed is not None:
750
+ lattice_block["scatter"] = {
751
+ "seed": lattice_config.scatter.seed,
752
+ "count": lattice_config.scatter.count,
753
+ }
754
+ if lattice_config.corners:
755
+ lattice_block["corners"] = {
756
+ fn_name: [list(corner) for corner in corners]
757
+ for fn_name, corners in lattice_config.corners.items()
758
+ }
759
+ data["lattice"] = lattice_block
760
+ return data
761
+
762
+
763
+ @dataclass
764
+ class ServiceConfig:
765
+ name: str
766
+ language: str
767
+ expose: ExposeConfig = field(default_factory=ExposeConfig)
768
+ # "auto" (Clang AST when libclang is importable, else the regex reader),
769
+ # "clang" (require the AST parser), or "regex" (force the fallback).
770
+ parser: str = "auto"
771
+ clang: ClangConfig = field(default_factory=ClangConfig)
772
+ # Shared native libraries under libraries/ that this service links
773
+ # against (design.md section 4). Each entry is a directory name, e.g.
774
+ # "common-cpp" -> libraries/common-cpp/ with its own CMakeLists.txt.
775
+ libraries: list[str] = field(default_factory=list)
776
+ # Directories searched for Fortran INCLUDE files (.INC), relative to the
777
+ # repo root. Legacy F77 keeps COMMON blocks and IMPLICIT statements there.
778
+ include_paths: list[str] = field(default_factory=list)
779
+ # Authentication for the generated HTTP API. See ApiConfig.
780
+ api: ApiConfig = field(default_factory=ApiConfig)
781
+ # -D flags for preprocessed Fortran (.F90/.F, or #ifdef in lowercase
782
+ # files), applied when nativegate runs gfortran's preprocessor to produce
783
+ # the `_expanded` copy. Same reason clang.defines exists for C++: the
784
+ # branches differ, and only the build knows which one is live.
785
+ fortran_defines: list[str] = field(default_factory=list)
786
+ # `state:`/`invariants:`/`ranges:` — layer 3 declarations
787
+ # (design-verification-layers.md §3.2-3.5). Optional: a service with no
788
+ # invariants.json coverage simply has an empty VerificationConfig.
789
+ verification: VerificationConfig = field(default_factory=VerificationConfig)
790
+
791
+ @classmethod
792
+ def load(cls, service_dir: Path) -> "ServiceConfig":
793
+ config_path = service_dir / CONFIG_FILENAME
794
+ if not config_path.exists():
795
+ raise FileNotFoundError(
796
+ f"No {CONFIG_FILENAME} found in {service_dir}. "
797
+ "Run `ngate create-service` first."
798
+ )
799
+ data = yaml.safe_load(config_path.read_text()) or {}
800
+ expose = _load_expose(data.get("expose"), config_path)
801
+ clang_data = data.get("clang") or {}
802
+ # `None` means "no restriction": `expose: all` or an empty `expose:`
803
+ # block (the historical permissive default) both bind whatever the
804
+ # parser finds, so there is no fixed name list to check `state:`/
805
+ # `invariants:` references against. An explicit `expose.functions:`
806
+ # list IS that fixed list, and is enforced.
807
+ known_functions = None if (expose.all or expose.is_empty) else set(expose.functions)
808
+ return cls(
809
+ name=data.get("name", service_dir.name),
810
+ language=_load_language(data.get("language"), service_dir, config_path),
811
+ expose=expose,
812
+ parser=str(data.get("parser") or "auto"),
813
+ clang=ClangConfig(
814
+ std=str(clang_data.get("std") or "c++17"),
815
+ include_paths=list(clang_data.get("include_paths") or []),
816
+ defines=list(clang_data.get("defines") or []),
817
+ extra_args=list(clang_data.get("extra_args") or []),
818
+ scalar_ref_functions=list(clang_data.get("scalar_ref_functions") or []),
819
+ ),
820
+ libraries=list(data.get("libraries") or []),
821
+ include_paths=list(data.get("include_paths") or []),
822
+ api=_load_api(data.get("api"), config_path),
823
+ fortran_defines=list((data.get("fortran") or {}).get("defines") or []),
824
+ verification=_load_verification(data, config_path, known_functions),
825
+ )
826
+
827
+ def save(self, service_dir: Path) -> None:
828
+ data = {
829
+ "name": self.name,
830
+ "language": self.language,
831
+ "expose": {
832
+ "classes": self.expose.classes,
833
+ "functions": self.expose.functions,
834
+ },
835
+ }
836
+ if self.expose.all is not None:
837
+ data["expose"]["all"] = self.expose.all
838
+ if self.parser != "auto":
839
+ data["parser"] = self.parser
840
+ clang = {
841
+ key: value
842
+ for key, value in {
843
+ "std": self.clang.std if self.clang.std != "c++17" else None,
844
+ "include_paths": self.clang.include_paths,
845
+ "defines": self.clang.defines,
846
+ "extra_args": self.clang.extra_args,
847
+ }.items()
848
+ if value
849
+ }
850
+ if clang:
851
+ data["clang"] = clang
852
+ if self.libraries:
853
+ data["libraries"] = self.libraries
854
+ if self.include_paths:
855
+ data["include_paths"] = self.include_paths
856
+ # Only written when it differs from the default, so an existing
857
+ # nativegate.yaml does not grow a key that says nothing.
858
+ if self.api.auth != "none":
859
+ data["api"] = {"auth": self.api.auth}
860
+ verification = _dump_verification(self.verification)
861
+ if verification:
862
+ data.update(verification)
863
+ (service_dir / CONFIG_FILENAME).write_text(yaml.dump(data, sort_keys=False))
864
+
865
+
866
+ def _load_api(api_data, config_path: Path) -> ApiConfig:
867
+ """Read the `api:` block.
868
+
869
+ An unrecognised auth mode is an error, not a fallback to `none`. Quietly
870
+ treating `api: {auth: apikey}` (a plausible typo) as "no authentication"
871
+ would produce exactly the silently-open service this setting exists to
872
+ prevent.
873
+ """
874
+ api_data = api_data or {}
875
+ if not isinstance(api_data, dict):
876
+ raise ConfigError(
877
+ f"{config_path}: `api:` must be a block, got {type(api_data).__name__}."
878
+ )
879
+ auth = str(api_data.get("auth") or "none").strip().lower()
880
+ if auth not in ("none", "api_key"):
881
+ raise ConfigError(
882
+ f"{config_path}: `api.auth: {auth}` is not understood. Use "
883
+ "`none` or `api_key`. Refusing to default to `none`, which would "
884
+ "leave the service open on a typo."
885
+ )
886
+ return ApiConfig(auth=auth)
887
+
888
+
889
+ def _load_expose(expose_data, config_path: Path) -> ExposeConfig:
890
+ """Read an `expose:` block, requiring an explicit opt-in for "bind everything".
891
+
892
+ `expose: all` (or `expose: {all: true}`) states the intent. An empty block
893
+ keeps the historical permissive behaviour — the C++ parsers depend on it,
894
+ and on `[[nativegate::expose]]` annotations in the source — but says so out
895
+ loud rather than binding a whole header on an unstated default.
896
+ """
897
+ if isinstance(expose_data, str):
898
+ if expose_data.strip().lower() != "all":
899
+ raise ConfigError(
900
+ f"{config_path}: `expose: {expose_data}` is not understood. Use "
901
+ "`expose: all`, or a block with `classes:`/`functions:` lists."
902
+ )
903
+ return ExposeConfig(all=True)
904
+
905
+ # Checked before the `or {}` below, which cannot tell `False` from an
906
+ # omitted key — `False or {}` is `{}`. That coercion turned an explicit
907
+ # `expose: false` ("bind nothing") into "not stated", which then took the
908
+ # permissive branch and bound the entire native API: the exact opposite of
909
+ # what was written, with only a warning suggesting the user say `expose:
910
+ # all`.
911
+ if isinstance(expose_data, bool):
912
+ return ExposeConfig(all=expose_data)
913
+
914
+ expose_data = expose_data or {}
915
+ if not isinstance(expose_data, dict):
916
+ raise ConfigError(
917
+ f"{config_path}: `expose:` must be a mapping or the word `all`, "
918
+ f"not {type(expose_data).__name__}."
919
+ )
920
+
921
+ all_value = expose_data.get("all")
922
+ if all_value is not None and not isinstance(all_value, bool):
923
+ raise ConfigError(
924
+ f"{config_path}: `expose.all` must be true or false, got {all_value!r}."
925
+ )
926
+
927
+ expose = ExposeConfig(
928
+ classes=list(expose_data.get("classes") or []),
929
+ functions=list(expose_data.get("functions") or []),
930
+ all=all_value,
931
+ )
932
+ if expose.is_empty and expose.all is None:
933
+ warnings.warn(
934
+ f"{config_path}: `expose:` is empty, so every symbol nativegate finds "
935
+ "will be bound unless the source carries [[nativegate::expose]] "
936
+ "annotations. Say so explicitly with `expose: all`, or list the "
937
+ "classes/functions you want under `expose.classes:` / "
938
+ "`expose.functions:`.",
939
+ ExposeWarning,
940
+ stacklevel=3,
941
+ )
942
+ return expose
943
+
944
+
945
+ def _load_language(value, service_dir: Path, config_path: Path) -> str:
946
+ """Require `language:`, or infer it from the sources — never default to cpp."""
947
+ from .discovery import detect_language # local: discovery has no config deps
948
+
949
+ def discovered() -> set[str]:
950
+ # Only native/ — bindings/generated/ holds generated .cpp for every
951
+ # service, Fortran ones included, and would poison the inference.
952
+ native_dir = service_dir / "native"
953
+ if not native_dir.is_dir():
954
+ return set()
955
+ return {
956
+ lang
957
+ for path in native_dir.rglob("*")
958
+ if path.is_file() and (lang := detect_language(path)) is not None
959
+ }
960
+
961
+ if value is None:
962
+ found = discovered()
963
+ if len(found) == 1:
964
+ return found.pop()
965
+ if not found:
966
+ raise ConfigError(
967
+ f"{config_path}: `language:` is missing and no native sources were "
968
+ f"found under {service_dir} to infer it from. Set `language:` to one "
969
+ f"of {', '.join(SUPPORTED_LANGUAGES)}."
970
+ )
971
+ raise ConfigError(
972
+ f"{config_path}: `language:` is missing and the sources under "
973
+ f"{service_dir} are mixed ({', '.join(sorted(found))}). Set `language:` "
974
+ "explicitly."
975
+ )
976
+
977
+ language = str(value).strip().lower()
978
+ if language not in SUPPORTED_LANGUAGES:
979
+ raise ConfigError(
980
+ f"{config_path}: unsupported `language: {value}`. Supported languages "
981
+ f"are {', '.join(SUPPORTED_LANGUAGES)}."
982
+ )
983
+
984
+ found = discovered()
985
+ if found and language not in found:
986
+ raise ConfigError(
987
+ f"{config_path}: `language: {language}` conflicts with the sources "
988
+ f"under {service_dir}, which are {', '.join(sorted(found))}. Fix "
989
+ "`language:` or remove the sources that do not belong."
990
+ )
991
+ return language