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.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- nativegate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""T11 — evaluate declared invariant properties over T9's lattice.
|
|
2
|
+
|
|
3
|
+
Spec: design-verification-layers.md section 3.2 (declared vocabulary,
|
|
4
|
+
`monotone` compares raw float64 with `>=`/`<=`, no tolerance) and section 3.6
|
|
5
|
+
(counterexample reporting: first failure, then a pinned deterministic
|
|
6
|
+
bisection, in a byte-identical message format). Also section 6: the word
|
|
7
|
+
"proved" must never appear in output; the vocabulary is "checked at N
|
|
8
|
+
points".
|
|
9
|
+
|
|
10
|
+
This module evaluates the three properties this pass implements —
|
|
11
|
+
`bounds`, `monotone`, `sum_to_one` — against a lattice built by
|
|
12
|
+
:mod:`nativegate.lattice`. `symmetric_in` and `scales_linearly_in` are declared
|
|
13
|
+
vocabulary words (config.py's INVARIANT_VOCABULARY) that have no evaluator
|
|
14
|
+
here yet; per the task, an unimplemented vocabulary word must be a loud,
|
|
15
|
+
hard error, never a silently-passing no-op -- see `NotImplementedInvariant`
|
|
16
|
+
and `evaluate_property`'s dispatch, which raises rather than skips.
|
|
17
|
+
|
|
18
|
+
T10 (setup replay as its own module) had not landed when this was written.
|
|
19
|
+
Rather than duplicate a full runner, `replay_setup` below implements only
|
|
20
|
+
what this module needs: replay `state.setup`'s entries, in `nativegate.yaml`
|
|
21
|
+
order, using the arguments `golden.json` recorded for each -- directly
|
|
22
|
+
against `config.StateConfig` and `golden.invoke`, with no dependency on a
|
|
23
|
+
T10 module that does not exist yet.
|
|
24
|
+
|
|
25
|
+
Ambiguities in spec section 3.6, resolved here and flagged in the task
|
|
26
|
+
report (search this file for "AMBIGUITY" to find each decision):
|
|
27
|
+
|
|
28
|
+
* The example message's numeric formatting is inconsistent between fields
|
|
29
|
+
(`min: 1.0` keeps a trailing `.0`; the lattice line's `10000` does not),
|
|
30
|
+
and is not itself specified as a formula anywhere in the document. This
|
|
31
|
+
module picks ONE deterministic rule (`_fmt`, below) and applies it to
|
|
32
|
+
every computed/swept value, and a second rule for property-label
|
|
33
|
+
thresholds (which echoes the declared YAML value's float form). Both are
|
|
34
|
+
fixed and produce byte-identical output across runs for the same failure,
|
|
35
|
+
which is the property section 3.6 actually requires ("the message is
|
|
36
|
+
byte-identical across runs and can itself be asserted in tests") --
|
|
37
|
+
matching the example's exact whitespace was not achievable from the text
|
|
38
|
+
alone since the example is not accompanied by a format string.
|
|
39
|
+
* The example's `bracket` line ends in a physical unit ("... 8437.50 psia").
|
|
40
|
+
No unit metadata exists anywhere in `config.py`'s dataclasses (checked:
|
|
41
|
+
`RangeDeclaration` carries only `lo`/`hi`). This implementation omits the
|
|
42
|
+
unit suffix rather than inventing a units feature the spec does not
|
|
43
|
+
describe elsewhere.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from typing import Callable, Sequence
|
|
50
|
+
|
|
51
|
+
from .config import (
|
|
52
|
+
BoundsProperty,
|
|
53
|
+
InvariantProperty,
|
|
54
|
+
MonotoneProperty,
|
|
55
|
+
ScalesLinearlyInProperty,
|
|
56
|
+
StateConfig,
|
|
57
|
+
SumToOneProperty,
|
|
58
|
+
SymmetricInProperty,
|
|
59
|
+
)
|
|
60
|
+
from .lattice import LatticePoint, ParameterSweep
|
|
61
|
+
|
|
62
|
+
MAX_BISECTION_STEPS = 40
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NotImplementedInvariant(NotImplementedError):
|
|
66
|
+
"""A declared vocabulary word with no evaluator this pass.
|
|
67
|
+
|
|
68
|
+
Raised, never swallowed: "an unimplemented vocabulary word must be a
|
|
69
|
+
hard error, never a silent pass" (task T11, item 1).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class InvariantSetupError(RuntimeError):
|
|
74
|
+
"""`state.setup` names a function with no golden.json entry to replay."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- value formatting (see module docstring: "Ambiguities") ----------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fmt(x: float) -> str:
|
|
81
|
+
"""Deterministic formatting for a *computed/swept* value.
|
|
82
|
+
|
|
83
|
+
Integral floats print without a trailing `.0` (`10000.0` -> `"10000"`),
|
|
84
|
+
matching the lattice line's range endpoints in the spec's own example;
|
|
85
|
+
everything else uses `repr()`, which is Python's shortest round-tripping
|
|
86
|
+
representation and therefore stable and exact.
|
|
87
|
+
"""
|
|
88
|
+
x = float(x)
|
|
89
|
+
if x == int(x) and abs(x) < 1e16:
|
|
90
|
+
return str(int(x))
|
|
91
|
+
return repr(x)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fmt_threshold(x: float) -> str:
|
|
95
|
+
"""Formatting for a property's declared threshold (e.g. `bounds.min`).
|
|
96
|
+
|
|
97
|
+
Echoes the float form the spec's example uses for `min: 1.0` -- always
|
|
98
|
+
showing the value as a float literal, trailing `.0` included -- since
|
|
99
|
+
this is restating a YAML-declared number, not a swept/computed one.
|
|
100
|
+
"""
|
|
101
|
+
return repr(float(x))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _label(prop: InvariantProperty) -> str:
|
|
105
|
+
"""The `<word>{...}` fragment inside the message's first line."""
|
|
106
|
+
if isinstance(prop, BoundsProperty):
|
|
107
|
+
parts = []
|
|
108
|
+
if prop.min is not None:
|
|
109
|
+
parts.append(f"min: {_fmt_threshold(prop.min)}")
|
|
110
|
+
if prop.max is not None:
|
|
111
|
+
parts.append(f"max: {_fmt_threshold(prop.max)}")
|
|
112
|
+
return f"bounds{{{', '.join(parts)}}}"
|
|
113
|
+
if isinstance(prop, MonotoneProperty):
|
|
114
|
+
return f"monotone{{in: {prop.parameter}, direction: {prop.direction}}}"
|
|
115
|
+
if isinstance(prop, SumToOneProperty):
|
|
116
|
+
return f"sum_to_one{{{', '.join(prop.fields)}}}"
|
|
117
|
+
raise AssertionError(f"unreachable: no label for {prop!r}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _row(label: str, body: str) -> str:
|
|
121
|
+
"""One ` <label padded to 14> : <body>` line (spec section 3.6's block)."""
|
|
122
|
+
return f" {label:<14}: {body}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- bisection (spec section 3.6, pinned) -----------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def bisect_bracket(
|
|
129
|
+
a: float,
|
|
130
|
+
b: float,
|
|
131
|
+
holds: Callable[[float], bool],
|
|
132
|
+
max_steps: int = MAX_BISECTION_STEPS,
|
|
133
|
+
) -> tuple[float, float, int]:
|
|
134
|
+
"""Deterministically bisect `[a, b]` toward the pass/fail boundary.
|
|
135
|
+
|
|
136
|
+
Precondition: `holds(a)` is True (a passes) and `holds(b)` is False (b
|
|
137
|
+
fails) -- the bracket the spec calls "the last passing lattice point and
|
|
138
|
+
the first failing one". Per spec: "the midpoint is `(a + b) / 2` in
|
|
139
|
+
float64; the side keeping the bracket valid is retained; iteration stops
|
|
140
|
+
when `(a + b) / 2` equals either endpoint (float64 exhaustion) or after
|
|
141
|
+
40 steps, whichever is first."
|
|
142
|
+
|
|
143
|
+
Returns `(a, b, steps_taken)` -- the tightest bracket found, still
|
|
144
|
+
satisfying `holds(a)` and not `holds(b)`.
|
|
145
|
+
"""
|
|
146
|
+
steps = 0
|
|
147
|
+
while steps < max_steps:
|
|
148
|
+
mid = (a + b) / 2
|
|
149
|
+
if mid == a or mid == b:
|
|
150
|
+
break
|
|
151
|
+
steps += 1
|
|
152
|
+
if holds(mid):
|
|
153
|
+
a = mid
|
|
154
|
+
else:
|
|
155
|
+
b = mid
|
|
156
|
+
return a, b, steps
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- reporting ---------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class PropertyReport:
|
|
164
|
+
function: str
|
|
165
|
+
property: InvariantProperty
|
|
166
|
+
passed: bool
|
|
167
|
+
points_checked: int
|
|
168
|
+
message: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _pass_message(function: str, prop: InvariantProperty, points_checked: int) -> str:
|
|
172
|
+
# Section 6: "The file should say 'checked at 33 points', and the CLI
|
|
173
|
+
# should never print the word 'proved'."
|
|
174
|
+
return f"invariant `{function}: {_label(prop)}` checked at {points_checked} points"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _failure_message(
|
|
178
|
+
*,
|
|
179
|
+
function: str,
|
|
180
|
+
prop: InvariantProperty,
|
|
181
|
+
parameter: str,
|
|
182
|
+
first_failure_x: float,
|
|
183
|
+
first_failure_value,
|
|
184
|
+
last_passing_x: float,
|
|
185
|
+
last_passing_value,
|
|
186
|
+
bracket_a: float,
|
|
187
|
+
bracket_b: float,
|
|
188
|
+
lattice_lo: float,
|
|
189
|
+
lattice_hi: float,
|
|
190
|
+
lattice_n: int,
|
|
191
|
+
lattice_index: int,
|
|
192
|
+
) -> str:
|
|
193
|
+
"""Byte-identical block per spec section 3.6::
|
|
194
|
+
|
|
195
|
+
invariant `oil_fvf: bounds{min: 1.0}` failed
|
|
196
|
+
first failure : pressure = 8437.50 -> 0.9994
|
|
197
|
+
last passing : pressure = 8125.00 -> 1.0002
|
|
198
|
+
bracket : the property breaks between 8125.00 and 8437.50 psia
|
|
199
|
+
lattice : pressure in [14.7, 10000], 33 points, index 27
|
|
200
|
+
|
|
201
|
+
(rendered here with the same four labelled rows; see the module
|
|
202
|
+
docstring for the two formatting/units ambiguities this resolves.)
|
|
203
|
+
"""
|
|
204
|
+
lines = [
|
|
205
|
+
f"invariant `{function}: {_label(prop)}` failed",
|
|
206
|
+
_row(
|
|
207
|
+
"first failure",
|
|
208
|
+
f"{parameter} = {_fmt(first_failure_x)} -> {_fmt(first_failure_value)}",
|
|
209
|
+
),
|
|
210
|
+
_row(
|
|
211
|
+
"last passing",
|
|
212
|
+
f"{parameter} = {_fmt(last_passing_x)} -> {_fmt(last_passing_value)}",
|
|
213
|
+
),
|
|
214
|
+
_row(
|
|
215
|
+
"bracket",
|
|
216
|
+
f"the property breaks between {_fmt(bracket_a)} and {_fmt(bracket_b)}",
|
|
217
|
+
),
|
|
218
|
+
_row(
|
|
219
|
+
"lattice",
|
|
220
|
+
f"{parameter} ∈ [{_fmt(lattice_lo)}, {_fmt(lattice_hi)}], "
|
|
221
|
+
f"{lattice_n} points, index {lattice_index}",
|
|
222
|
+
),
|
|
223
|
+
]
|
|
224
|
+
return "\n".join(lines)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --- property evaluators -------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _replaced(arguments: tuple, position: int, value) -> tuple:
|
|
231
|
+
return arguments[:position] + (value,) + arguments[position + 1 :]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _call_at(fn: Callable, sweep: ParameterSweep, position: int, base_arguments: tuple, x: float):
|
|
235
|
+
return fn(*_replaced(base_arguments, position, x))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _bounds_holds(prop: BoundsProperty, value: float) -> bool:
|
|
239
|
+
if prop.min is not None and not (value >= prop.min):
|
|
240
|
+
return False
|
|
241
|
+
if prop.max is not None and not (value <= prop.max):
|
|
242
|
+
return False
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def evaluate_bounds(
|
|
247
|
+
fn: Callable,
|
|
248
|
+
sweep: ParameterSweep,
|
|
249
|
+
prop: BoundsProperty,
|
|
250
|
+
function_name: str,
|
|
251
|
+
*,
|
|
252
|
+
position: int,
|
|
253
|
+
base_arguments: tuple,
|
|
254
|
+
) -> PropertyReport:
|
|
255
|
+
"""Check `bounds` at every point of one parameter's sweep, in order."""
|
|
256
|
+
values = [fn(*point.arguments) for point in sweep.points]
|
|
257
|
+
passes = [_bounds_holds(prop, v) for v in values]
|
|
258
|
+
|
|
259
|
+
if all(passes):
|
|
260
|
+
return PropertyReport(
|
|
261
|
+
function=function_name,
|
|
262
|
+
property=prop,
|
|
263
|
+
passed=True,
|
|
264
|
+
points_checked=len(sweep.points),
|
|
265
|
+
message=_pass_message(function_name, prop, len(sweep.points)),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
first_fail_index = passes.index(False)
|
|
269
|
+
if first_fail_index == 0:
|
|
270
|
+
# No passing point precedes the failure: there is nothing to bracket.
|
|
271
|
+
# Report the bare failure (still never the word "proved").
|
|
272
|
+
message = (
|
|
273
|
+
f"invariant `{function_name}: {_label(prop)}` failed\n"
|
|
274
|
+
+ _row(
|
|
275
|
+
"first failure",
|
|
276
|
+
f"{sweep.parameter} = {_fmt(sweep.points[0].arguments[position])}"
|
|
277
|
+
f" -> {_fmt(values[0])}",
|
|
278
|
+
)
|
|
279
|
+
+ "\n"
|
|
280
|
+
+ _row(
|
|
281
|
+
"lattice",
|
|
282
|
+
f"{sweep.parameter} ∈ [{_fmt(sweep.lo)}, {_fmt(sweep.hi)}], "
|
|
283
|
+
f"{len(sweep.points)} points, index 0 (no passing point precedes it)",
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
return PropertyReport(
|
|
287
|
+
function=function_name,
|
|
288
|
+
property=prop,
|
|
289
|
+
passed=False,
|
|
290
|
+
points_checked=len(sweep.points),
|
|
291
|
+
message=message,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
last_pass_index = first_fail_index - 1
|
|
295
|
+
a = sweep.points[last_pass_index].arguments[position]
|
|
296
|
+
b = sweep.points[first_fail_index].arguments[position]
|
|
297
|
+
|
|
298
|
+
def holds(x: float) -> bool:
|
|
299
|
+
return _bounds_holds(prop, _call_at(fn, sweep, position, base_arguments, x))
|
|
300
|
+
|
|
301
|
+
bracket_a, bracket_b, _steps = bisect_bracket(a, b, holds)
|
|
302
|
+
|
|
303
|
+
message = _failure_message(
|
|
304
|
+
function=function_name,
|
|
305
|
+
prop=prop,
|
|
306
|
+
parameter=sweep.parameter,
|
|
307
|
+
first_failure_x=b,
|
|
308
|
+
first_failure_value=values[first_fail_index],
|
|
309
|
+
last_passing_x=a,
|
|
310
|
+
last_passing_value=values[last_pass_index],
|
|
311
|
+
bracket_a=bracket_a,
|
|
312
|
+
bracket_b=bracket_b,
|
|
313
|
+
lattice_lo=sweep.lo,
|
|
314
|
+
lattice_hi=sweep.hi,
|
|
315
|
+
lattice_n=len(sweep.points),
|
|
316
|
+
lattice_index=first_fail_index,
|
|
317
|
+
)
|
|
318
|
+
return PropertyReport(
|
|
319
|
+
function=function_name,
|
|
320
|
+
property=prop,
|
|
321
|
+
passed=False,
|
|
322
|
+
points_checked=len(sweep.points),
|
|
323
|
+
message=message,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _monotone_holds(prop: MonotoneProperty, previous: float, current: float) -> bool:
|
|
328
|
+
# Spec section 3.2: "raw float64 values with >=/<=, no tolerance."
|
|
329
|
+
if prop.direction == "nondecreasing":
|
|
330
|
+
return current >= previous
|
|
331
|
+
return current <= previous
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def evaluate_monotone(
|
|
335
|
+
fn: Callable,
|
|
336
|
+
sweep: ParameterSweep,
|
|
337
|
+
prop: MonotoneProperty,
|
|
338
|
+
function_name: str,
|
|
339
|
+
*,
|
|
340
|
+
position: int,
|
|
341
|
+
base_arguments: tuple,
|
|
342
|
+
) -> PropertyReport:
|
|
343
|
+
"""Check `monotone` between every consecutive pair of a sweep, in order."""
|
|
344
|
+
values = [fn(*point.arguments) for point in sweep.points]
|
|
345
|
+
|
|
346
|
+
first_fail_index = None
|
|
347
|
+
for i in range(1, len(values)):
|
|
348
|
+
if not _monotone_holds(prop, values[i - 1], values[i]):
|
|
349
|
+
first_fail_index = i
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
if first_fail_index is None:
|
|
353
|
+
return PropertyReport(
|
|
354
|
+
function=function_name,
|
|
355
|
+
property=prop,
|
|
356
|
+
passed=True,
|
|
357
|
+
points_checked=len(sweep.points),
|
|
358
|
+
message=_pass_message(function_name, prop, len(sweep.points)),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
last_pass_index = first_fail_index - 1
|
|
362
|
+
a = sweep.points[last_pass_index].arguments[position]
|
|
363
|
+
b = sweep.points[first_fail_index].arguments[position]
|
|
364
|
+
anchor_value = values[last_pass_index]
|
|
365
|
+
|
|
366
|
+
def holds(x: float) -> bool:
|
|
367
|
+
# "Holds" here means: monotonicity from the anchor (last known-good
|
|
368
|
+
# point) to `x` still holds -- i.e. the candidate `x` has not (yet)
|
|
369
|
+
# reproduced the violation. This keeps the same bisect_bracket
|
|
370
|
+
# contract (holds(a) True, holds(b) False) used for `bounds`.
|
|
371
|
+
candidate_value = _call_at(fn, sweep, position, base_arguments, x)
|
|
372
|
+
return _monotone_holds(prop, anchor_value, candidate_value)
|
|
373
|
+
|
|
374
|
+
bracket_a, bracket_b, _steps = bisect_bracket(a, b, holds)
|
|
375
|
+
|
|
376
|
+
message = _failure_message(
|
|
377
|
+
function=function_name,
|
|
378
|
+
prop=prop,
|
|
379
|
+
parameter=sweep.parameter,
|
|
380
|
+
first_failure_x=b,
|
|
381
|
+
first_failure_value=values[first_fail_index],
|
|
382
|
+
last_passing_x=a,
|
|
383
|
+
last_passing_value=values[last_pass_index],
|
|
384
|
+
bracket_a=bracket_a,
|
|
385
|
+
bracket_b=bracket_b,
|
|
386
|
+
lattice_lo=sweep.lo,
|
|
387
|
+
lattice_hi=sweep.hi,
|
|
388
|
+
lattice_n=len(sweep.points),
|
|
389
|
+
lattice_index=first_fail_index,
|
|
390
|
+
)
|
|
391
|
+
return PropertyReport(
|
|
392
|
+
function=function_name,
|
|
393
|
+
property=prop,
|
|
394
|
+
passed=False,
|
|
395
|
+
points_checked=len(sweep.points),
|
|
396
|
+
message=message,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _sum_to_one_holds(prop: SumToOneProperty, result: dict) -> bool:
|
|
401
|
+
total = sum(result[field] for field in prop.fields)
|
|
402
|
+
return abs(total - 1.0) <= prop.tolerance
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def evaluate_sum_to_one(
|
|
406
|
+
fn: Callable,
|
|
407
|
+
sweep: ParameterSweep,
|
|
408
|
+
prop: SumToOneProperty,
|
|
409
|
+
function_name: str,
|
|
410
|
+
*,
|
|
411
|
+
position: int,
|
|
412
|
+
base_arguments: tuple,
|
|
413
|
+
) -> PropertyReport:
|
|
414
|
+
"""Check `sum_to_one` at every point of one parameter's sweep, in order.
|
|
415
|
+
|
|
416
|
+
`fn` is expected to return a mapping with (at least) `prop.fields` as
|
|
417
|
+
keys -- the multi-field result `sum_to_one` is declared over (spec
|
|
418
|
+
section 3.2's `saturations: sum_to_one: [sw, so, sg]` example).
|
|
419
|
+
"""
|
|
420
|
+
results = [fn(*point.arguments) for point in sweep.points]
|
|
421
|
+
sums = [sum(r[field] for r in [result] for field in prop.fields) for result in results]
|
|
422
|
+
passes = [_sum_to_one_holds(prop, r) for r in results]
|
|
423
|
+
|
|
424
|
+
if all(passes):
|
|
425
|
+
return PropertyReport(
|
|
426
|
+
function=function_name,
|
|
427
|
+
property=prop,
|
|
428
|
+
passed=True,
|
|
429
|
+
points_checked=len(sweep.points),
|
|
430
|
+
message=_pass_message(function_name, prop, len(sweep.points)),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
first_fail_index = passes.index(False)
|
|
434
|
+
if first_fail_index == 0:
|
|
435
|
+
message = (
|
|
436
|
+
f"invariant `{function_name}: {_label(prop)}` failed\n"
|
|
437
|
+
+ _row(
|
|
438
|
+
"first failure",
|
|
439
|
+
f"{sweep.parameter} = {_fmt(sweep.points[0].arguments[position])}"
|
|
440
|
+
f" -> {_fmt(sums[0])}",
|
|
441
|
+
)
|
|
442
|
+
+ "\n"
|
|
443
|
+
+ _row(
|
|
444
|
+
"lattice",
|
|
445
|
+
f"{sweep.parameter} ∈ [{_fmt(sweep.lo)}, {_fmt(sweep.hi)}], "
|
|
446
|
+
f"{len(sweep.points)} points, index 0 (no passing point precedes it)",
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
return PropertyReport(
|
|
450
|
+
function=function_name,
|
|
451
|
+
property=prop,
|
|
452
|
+
passed=False,
|
|
453
|
+
points_checked=len(sweep.points),
|
|
454
|
+
message=message,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
last_pass_index = first_fail_index - 1
|
|
458
|
+
a = sweep.points[last_pass_index].arguments[position]
|
|
459
|
+
b = sweep.points[first_fail_index].arguments[position]
|
|
460
|
+
|
|
461
|
+
def holds(x: float) -> bool:
|
|
462
|
+
result = _call_at(fn, sweep, position, base_arguments, x)
|
|
463
|
+
return _sum_to_one_holds(prop, result)
|
|
464
|
+
|
|
465
|
+
bracket_a, bracket_b, _steps = bisect_bracket(a, b, holds)
|
|
466
|
+
|
|
467
|
+
message = _failure_message(
|
|
468
|
+
function=function_name,
|
|
469
|
+
prop=prop,
|
|
470
|
+
parameter=sweep.parameter,
|
|
471
|
+
first_failure_x=b,
|
|
472
|
+
first_failure_value=sums[first_fail_index],
|
|
473
|
+
last_passing_x=a,
|
|
474
|
+
last_passing_value=sums[last_pass_index],
|
|
475
|
+
bracket_a=bracket_a,
|
|
476
|
+
bracket_b=bracket_b,
|
|
477
|
+
lattice_lo=sweep.lo,
|
|
478
|
+
lattice_hi=sweep.hi,
|
|
479
|
+
lattice_n=len(sweep.points),
|
|
480
|
+
lattice_index=first_fail_index,
|
|
481
|
+
)
|
|
482
|
+
return PropertyReport(
|
|
483
|
+
function=function_name,
|
|
484
|
+
property=prop,
|
|
485
|
+
passed=False,
|
|
486
|
+
points_checked=len(sweep.points),
|
|
487
|
+
message=message,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def evaluate_property(
|
|
492
|
+
fn: Callable,
|
|
493
|
+
sweep: ParameterSweep,
|
|
494
|
+
prop: InvariantProperty,
|
|
495
|
+
function_name: str,
|
|
496
|
+
*,
|
|
497
|
+
position: int,
|
|
498
|
+
base_arguments: tuple,
|
|
499
|
+
) -> PropertyReport:
|
|
500
|
+
"""Dispatch to the right evaluator, or raise for an unimplemented word.
|
|
501
|
+
|
|
502
|
+
`symmetric_in` and `scales_linearly_in` are in config.py's closed
|
|
503
|
+
vocabulary but have no evaluator this pass: per T11 item 1, that must be
|
|
504
|
+
a loud error, never a silently-passing no-op.
|
|
505
|
+
"""
|
|
506
|
+
if isinstance(prop, BoundsProperty):
|
|
507
|
+
return evaluate_bounds(
|
|
508
|
+
fn, sweep, prop, function_name, position=position, base_arguments=base_arguments
|
|
509
|
+
)
|
|
510
|
+
if isinstance(prop, MonotoneProperty):
|
|
511
|
+
return evaluate_monotone(
|
|
512
|
+
fn, sweep, prop, function_name, position=position, base_arguments=base_arguments
|
|
513
|
+
)
|
|
514
|
+
if isinstance(prop, SumToOneProperty):
|
|
515
|
+
return evaluate_sum_to_one(
|
|
516
|
+
fn, sweep, prop, function_name, position=position, base_arguments=base_arguments
|
|
517
|
+
)
|
|
518
|
+
if isinstance(prop, (SymmetricInProperty, ScalesLinearlyInProperty)):
|
|
519
|
+
word = "symmetric_in" if isinstance(prop, SymmetricInProperty) else "scales_linearly_in"
|
|
520
|
+
raise NotImplementedInvariant(
|
|
521
|
+
f"invariants.{function_name}: `{word}` is a declared vocabulary word "
|
|
522
|
+
"with no evaluator implemented yet (T11 stub). This is a hard "
|
|
523
|
+
"error, not a skip -- an unimplemented property must never look "
|
|
524
|
+
"like a pass."
|
|
525
|
+
)
|
|
526
|
+
raise AssertionError(f"unreachable: no evaluator for {prop!r}")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# --- minimal setup replay (T10 had not landed; see module docstring) -------
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def replay_setup(state: StateConfig, golden_document: dict, package) -> None:
|
|
533
|
+
"""Replay `state.setup`'s entries, in `nativegate.yaml` order.
|
|
534
|
+
|
|
535
|
+
Spec section 3.5: "Every property evaluation runs after the setup
|
|
536
|
+
sequence, replayed with the arguments recorded in golden.json." This
|
|
537
|
+
finds each setup function's entry by its recorded `name` (golden.json's
|
|
538
|
+
entries are keyed by an arbitrary key, but each entry carries the
|
|
539
|
+
function name it calls) and replays it via `golden.invoke`, which is the
|
|
540
|
+
same call path golden itself uses.
|
|
541
|
+
"""
|
|
542
|
+
from . import golden # local: avoid a hard import cycle at module load
|
|
543
|
+
|
|
544
|
+
entries = golden_document.get("entries") or {}
|
|
545
|
+
entries_by_function: dict = {}
|
|
546
|
+
for entry in entries.values():
|
|
547
|
+
entries_by_function.setdefault(entry.get("name"), []).append(entry)
|
|
548
|
+
|
|
549
|
+
# A function name can appear more than once in `state.setup` (e.g. an
|
|
550
|
+
# init-then-update sequence); each occurrence consumes the *next*
|
|
551
|
+
# golden.json entry recorded for that function, in file order, rather
|
|
552
|
+
# than always replaying the first one.
|
|
553
|
+
next_index: dict = {}
|
|
554
|
+
for fn_name in state.setup:
|
|
555
|
+
candidates = entries_by_function.get(fn_name) or []
|
|
556
|
+
idx = next_index.get(fn_name, 0)
|
|
557
|
+
if idx >= len(candidates):
|
|
558
|
+
raise InvariantSetupError(
|
|
559
|
+
f"state.setup names {fn_name!r}, which has no golden.json "
|
|
560
|
+
"entry to replay -- setup must be replayed with recorded "
|
|
561
|
+
"arguments, and none were recorded for this function "
|
|
562
|
+
f"(occurrence {idx + 1} of {fn_name!r} in state.setup)."
|
|
563
|
+
)
|
|
564
|
+
golden.invoke(candidates[idx], package)
|
|
565
|
+
next_index[fn_name] = idx + 1
|