redroot 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {redroot-0.2.0 → redroot-0.3.0}/CHANGELOG.md +18 -0
- {redroot-0.2.0 → redroot-0.3.0}/PKG-INFO +23 -2
- {redroot-0.2.0 → redroot-0.3.0}/README.md +22 -1
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/__init__.py +5 -3
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/_core.py +14 -1
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/instrument.py +65 -8
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/ops.py +3 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/serialization.py +2 -1
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/trace.py +77 -37
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/types.py +37 -10
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/validation.py +67 -2
- redroot-0.3.0/tests/test_bools.py +271 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_instrument.py +11 -4
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_properties.py +20 -9
- {redroot-0.2.0 → redroot-0.3.0}/.gitignore +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/LICENSE +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/pyproject.toml +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/cli.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/functions.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/paths.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/propagation.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/py.typed +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/__init__.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/data.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/graphviz.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/networkx.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/__init__.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/server.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/static/app.js +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/static/index.html +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/static/style.css +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/__init__.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/conftest.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/helpers.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_cli.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_docs.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_functions.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_paths.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_propagation.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_pydantic.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_regressions.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_serialization.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_trace.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_types.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_validation.py +0 -0
- {redroot-0.2.0 → redroot-0.3.0}/tests/test_visualizers.py +0 -0
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Boolean tracing, opt-in with `Trace(bools=True)` (or `run(..., bools=True)`):
|
|
14
|
+
comparisons return `TracedBool` values that are data rather than guards, so
|
|
15
|
+
flags that are stored or computed with propagate exactly; a guard is
|
|
16
|
+
recorded only where a flag's truth value is used. Boolean inputs become
|
|
17
|
+
editable inputs. Instrumented code also routes `not`, `x is True/False`,
|
|
18
|
+
`isinstance(x, bool)` and `x in traced_str`, and passes real `bool`s to
|
|
19
|
+
external code.
|
|
20
|
+
- `validation.check_fidelity()`: run a workflow plainly and traced and
|
|
21
|
+
compare their outputs, catching differences tracing itself introduces.
|
|
22
|
+
- `redroot.unwrap_deep()` is public.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- `explain()` no longer doubles parentheses around operator expressions
|
|
26
|
+
passed to calls, e.g. `round(x * 1.1, 2)` instead of `round((x * 1.1), 2)`.
|
|
27
|
+
|
|
10
28
|
## [0.2.0] - 2026-10-02
|
|
11
29
|
|
|
12
30
|
Re-evaluable traces: RedRoot now answers "what does every output become if
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: redroot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Value-level lineage for Python: trace where every output came from, and propagate input edits to every output without re-running
|
|
5
5
|
Project-URL: Homepage, https://github.com/gagan-gaurav/redthread
|
|
6
6
|
Project-URL: Documentation, https://github.com/gagan-gaurav/redthread#readme
|
|
@@ -283,6 +283,27 @@ report = check_perturbation(guarded, {"A": 1200, "B": 300}, {"ext:A": 1100}, inp
|
|
|
283
283
|
assert report.consistent # graph agrees with re-execution wherever it claimed to know
|
|
284
284
|
```
|
|
285
285
|
|
|
286
|
+
### Booleans
|
|
287
|
+
|
|
288
|
+
Python does not allow subclassing `bool`, so by default comparisons return plain `bool`s (recorded as guards) and boolean inputs are untracked. Pass `bools=True` to trace them too:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
def form(ext):
|
|
292
|
+
eligible = ext["income"] > 30000
|
|
293
|
+
return {"eligible": eligible, "credit": 500 * eligible + 100 * ext["married"]}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
trace, _ = rr.run(form, {"income": 25000, "married": True}, input_root="ext", bools=True)
|
|
297
|
+
print(trace.explain("out:eligible")) # ext:income > 30000
|
|
298
|
+
|
|
299
|
+
result = trace.propagate({"ext:income": 40000}) # the comparison flips...
|
|
300
|
+
print(result.exact, result.outputs["out:credit"].new) # True 600
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
A comparison then returns a `TracedBool`, which is data: a flag that is stored, counted or multiplied propagates exactly when it changes. A guard is recorded only where its truth value is used (`if`, `while`, `and`/`or`), and boolean inputs such as checkboxes become editable inputs.
|
|
304
|
+
|
|
305
|
+
A `TracedBool` is an `int` subclass that prints, formats, hashes and compares like `True`/`False`. In code you do not instrument, `isinstance(x, bool)` and `x is True` are false and `json.dumps` writes `1`/`0`. Instrumented code gets all three right: external calls receive real `bool`s. Use `redroot.validation.check_fidelity(workflow, inputs, bools=True)` to confirm that tracing leaves a workflow's outputs unchanged.
|
|
306
|
+
|
|
286
307
|
### Pydantic
|
|
287
308
|
|
|
288
309
|
With `redroot[pydantic]`, traced types work as model fields. Traced values pass through validation with their lineage, plain values become inputs, and models serialize to plain values.
|
|
@@ -295,7 +316,7 @@ Overhead is about 0.7 µs per traced operation (0.9 µs for `Decimal`) and about
|
|
|
295
316
|
|
|
296
317
|
## Limitations
|
|
297
318
|
|
|
298
|
-
-
|
|
319
|
+
- `int`, `float`, `Decimal` and `str` are traced, and `bool` with `bools=True`. `None` inputs (and `bool` inputs otherwise) are recorded as untracked: editing them requires re-execution.
|
|
299
320
|
- Without instrumentation, a few C-level operations lose lineage (see above). `redroot.validation` finds them. [docs/design.md](docs/design.md#what-remains-invisible) lists what remains invisible even with it.
|
|
300
321
|
- Lineage does not cross threads unless the context is propagated (`contextvars.copy_context()`), nor processes, nor `pickle`.
|
|
301
322
|
- `type(x) is float` is `False` for traced values (instrumented code is unaffected).
|
|
@@ -236,6 +236,27 @@ report = check_perturbation(guarded, {"A": 1200, "B": 300}, {"ext:A": 1100}, inp
|
|
|
236
236
|
assert report.consistent # graph agrees with re-execution wherever it claimed to know
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
+
### Booleans
|
|
240
|
+
|
|
241
|
+
Python does not allow subclassing `bool`, so by default comparisons return plain `bool`s (recorded as guards) and boolean inputs are untracked. Pass `bools=True` to trace them too:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
def form(ext):
|
|
245
|
+
eligible = ext["income"] > 30000
|
|
246
|
+
return {"eligible": eligible, "credit": 500 * eligible + 100 * ext["married"]}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
trace, _ = rr.run(form, {"income": 25000, "married": True}, input_root="ext", bools=True)
|
|
250
|
+
print(trace.explain("out:eligible")) # ext:income > 30000
|
|
251
|
+
|
|
252
|
+
result = trace.propagate({"ext:income": 40000}) # the comparison flips...
|
|
253
|
+
print(result.exact, result.outputs["out:credit"].new) # True 600
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
A comparison then returns a `TracedBool`, which is data: a flag that is stored, counted or multiplied propagates exactly when it changes. A guard is recorded only where its truth value is used (`if`, `while`, `and`/`or`), and boolean inputs such as checkboxes become editable inputs.
|
|
257
|
+
|
|
258
|
+
A `TracedBool` is an `int` subclass that prints, formats, hashes and compares like `True`/`False`. In code you do not instrument, `isinstance(x, bool)` and `x is True` are false and `json.dumps` writes `1`/`0`. Instrumented code gets all three right: external calls receive real `bool`s. Use `redroot.validation.check_fidelity(workflow, inputs, bools=True)` to confirm that tracing leaves a workflow's outputs unchanged.
|
|
259
|
+
|
|
239
260
|
### Pydantic
|
|
240
261
|
|
|
241
262
|
With `redroot[pydantic]`, traced types work as model fields. Traced values pass through validation with their lineage, plain values become inputs, and models serialize to plain values.
|
|
@@ -248,7 +269,7 @@ Overhead is about 0.7 µs per traced operation (0.9 µs for `Decimal`) and about
|
|
|
248
269
|
|
|
249
270
|
## Limitations
|
|
250
271
|
|
|
251
|
-
-
|
|
272
|
+
- `int`, `float`, `Decimal` and `str` are traced, and `bool` with `bools=True`. `None` inputs (and `bool` inputs otherwise) are recorded as untracked: editing them requires re-execution.
|
|
252
273
|
- Without instrumentation, a few C-level operations lose lineage (see above). `redroot.validation` finds them. [docs/design.md](docs/design.md#what-remains-invisible) lists what remains invisible even with it.
|
|
253
274
|
- Lineage does not cross threads unless the context is propagated (`contextvars.copy_context()`), nor processes, nor `pickle`.
|
|
254
275
|
- `type(x) is float` is `False` for traced values (instrumented code is unaffected).
|
|
@@ -15,7 +15,7 @@ every output becomes when an input changes, without running it again::
|
|
|
15
15
|
See the README for the full guide.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
from redroot._core import Node, Traced, active_trace, node_of, unwrap
|
|
18
|
+
from redroot._core import Node, Traced, active_trace, node_of, unwrap, unwrap_deep
|
|
19
19
|
from redroot.functions import annotate, derive, traced, traced_llm
|
|
20
20
|
from redroot.paths import apply_edits, format_key, parse_key
|
|
21
21
|
from redroot.propagation import (
|
|
@@ -28,9 +28,9 @@ from redroot.propagation import (
|
|
|
28
28
|
Verification,
|
|
29
29
|
)
|
|
30
30
|
from redroot.trace import Output, Trace, run, track
|
|
31
|
-
from redroot.types import TracedDecimal, TracedFloat, TracedInt, TracedStr
|
|
31
|
+
from redroot.types import TracedBool, TracedDecimal, TracedFloat, TracedInt, TracedStr
|
|
32
32
|
|
|
33
|
-
__version__ = "0.
|
|
33
|
+
__version__ = "0.3.0"
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def is_traced(value: object) -> bool:
|
|
@@ -49,6 +49,7 @@ __all__ = [
|
|
|
49
49
|
"Status",
|
|
50
50
|
"Trace",
|
|
51
51
|
"Traced",
|
|
52
|
+
"TracedBool",
|
|
52
53
|
"TracedDecimal",
|
|
53
54
|
"TracedFloat",
|
|
54
55
|
"TracedInt",
|
|
@@ -68,4 +69,5 @@ __all__ = [
|
|
|
68
69
|
"traced_llm",
|
|
69
70
|
"track",
|
|
70
71
|
"unwrap",
|
|
72
|
+
"unwrap_deep",
|
|
71
73
|
]
|
|
@@ -349,7 +349,7 @@ def record_result(
|
|
|
349
349
|
if isinstance(value, Traced) or kind is list or kind is dict or isinstance(value, tuple):
|
|
350
350
|
value = unwrap_deep(value) # e.g. a function returned one of its traced arguments
|
|
351
351
|
factory = _WRAPPERS.get(type(value))
|
|
352
|
-
if factory is not None:
|
|
352
|
+
if factory is not None and (type(value) is not bool or trace._bools):
|
|
353
353
|
return factory(
|
|
354
354
|
value, add_node(trace, op, args, kwargs, value, OP, meta=meta, ctx=ctx, fn=fn)
|
|
355
355
|
)
|
|
@@ -554,3 +554,16 @@ def observe_value(op: str, fn: Callable[..., Any], *operands: Any) -> Any:
|
|
|
554
554
|
raise
|
|
555
555
|
add_node(trace, op, tuple(refs), None, result, GUARD)
|
|
556
556
|
return result
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def compare(op: str, fn: Callable[[Any, Any], Any], left: Any, right: Any) -> Any:
|
|
560
|
+
"""Record a comparison of traced values.
|
|
561
|
+
|
|
562
|
+
In a trace that traces booleans the outcome is a traced boolean (data),
|
|
563
|
+
and a guard is recorded only where its truth value is used. Otherwise the
|
|
564
|
+
plain ``bool`` is returned and recorded as a guard right away.
|
|
565
|
+
"""
|
|
566
|
+
trace = _active.get()
|
|
567
|
+
if trace is not None and not isinstance(trace, Observer) and trace._bools:
|
|
568
|
+
return binary(op, fn, left, right)
|
|
569
|
+
return observe_value(op, fn, left, right)
|
|
@@ -52,6 +52,7 @@ from redroot.ops import (
|
|
|
52
52
|
STR_METHODS,
|
|
53
53
|
fstring,
|
|
54
54
|
)
|
|
55
|
+
from redroot.types import TracedBool
|
|
55
56
|
|
|
56
57
|
__all__ = ["RUNTIME_NAME", "compile_source", "exec_source", "instrument"]
|
|
57
58
|
|
|
@@ -253,18 +254,22 @@ def _opaque_call(func: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> An
|
|
|
253
254
|
The guard cannot be re-evaluated, so any change to those inputs means
|
|
254
255
|
re-execution: sound, if conservative.
|
|
255
256
|
"""
|
|
256
|
-
|
|
257
|
-
if trace is None or _is_transparent(func):
|
|
257
|
+
if _is_transparent(func):
|
|
258
258
|
return func(*args, **kwargs)
|
|
259
|
+
# A traced boolean is an int: external code (json, pydantic...) gets the bool.
|
|
260
|
+
plain_args, plain_kwargs = _plain_bools(args), _plain_bools(kwargs)
|
|
261
|
+
trace = _core.active_trace()
|
|
262
|
+
if trace is None:
|
|
263
|
+
return func(*plain_args, **plain_kwargs)
|
|
259
264
|
refs, _, linked = _core.encode(args, trace)
|
|
260
265
|
kw_refs, _, kw_linked = _core.encode(kwargs, trace)
|
|
261
266
|
if not (linked or kw_linked):
|
|
262
|
-
return func(*
|
|
267
|
+
return func(*plain_args, **plain_kwargs)
|
|
263
268
|
name = getattr(func, "__qualname__", None) or type(func).__qualname__
|
|
264
269
|
op = f"call:{getattr(func, '__module__', None) or ''}.{name}".replace(":.", ":")
|
|
265
270
|
first_new_node = len(trace.nodes)
|
|
266
271
|
try:
|
|
267
|
-
result = func(*
|
|
272
|
+
result = func(*plain_args, **plain_kwargs)
|
|
268
273
|
except Exception as exc:
|
|
269
274
|
_core.record_raise(trace, op, tuple(refs), kw_refs or None, exc, _core.NOT_REPLAYABLE)
|
|
270
275
|
raise
|
|
@@ -305,6 +310,8 @@ def call(func: Any, /, *args: Any, **kwargs: Any) -> Any:
|
|
|
305
310
|
return func(*args, **kwargs)
|
|
306
311
|
if func is type and len(args) == 1 and not kwargs and isinstance(args[0], Traced):
|
|
307
312
|
return args[0]._base # what type() returns for the plain value
|
|
313
|
+
if func is isinstance and len(args) == 2 and isinstance(args[0], TracedBool):
|
|
314
|
+
return isinstance(args[0], args[1]) or isinstance(args[0]._rt_node.value, args[1])
|
|
308
315
|
receiver = getattr(func, "__self__", None)
|
|
309
316
|
method = _METHODS.get(type(receiver))
|
|
310
317
|
if method is not None and func.__name__ in method[1]:
|
|
@@ -360,16 +367,49 @@ def ibinop(op: str, left: Any, right: Any) -> Any:
|
|
|
360
367
|
return _INPLACE[op](left, right)
|
|
361
368
|
|
|
362
369
|
|
|
370
|
+
_IDENTITY_OPS: dict[str, Callable[[Any, Any], Any]] = {
|
|
371
|
+
"is": operator.is_,
|
|
372
|
+
"is not": operator.is_not,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
363
376
|
def compare(op: str, left: Any, right: Any) -> Any:
|
|
364
|
-
"""``left <op> right`` for a single comparison
|
|
377
|
+
"""``left <op> right`` for a single comparison of traced values."""
|
|
365
378
|
if op in ("in", "not in"):
|
|
366
|
-
if isinstance(
|
|
379
|
+
if isinstance(right, Traced):
|
|
380
|
+
# `in` forces its result to a plain bool; ask the container directly.
|
|
381
|
+
found = _core.compare("contains", operator.contains, right, left)
|
|
382
|
+
return not_(found) if op == "not in" else found
|
|
383
|
+
if isinstance(left, Traced):
|
|
367
384
|
left = observe_value("observe", _identity, left) # membership in a plain container
|
|
368
385
|
result = left in right
|
|
369
386
|
return not result if op == "not in" else result
|
|
387
|
+
fn = _IDENTITY_OPS.get(op) or COMPARISON_OPS[op][0]
|
|
370
388
|
if isinstance(left, Traced) or isinstance(right, Traced):
|
|
371
|
-
|
|
372
|
-
|
|
389
|
+
# Identity is checked on plain values: a traced True "is" True.
|
|
390
|
+
return _core.compare(op, fn, left, right)
|
|
391
|
+
return fn(left, right)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def not_(value: Any) -> Any:
|
|
395
|
+
"""``not value``: recorded for traced values (a traced boolean if traced)."""
|
|
396
|
+
if isinstance(value, Traced):
|
|
397
|
+
return _core.call("not", operator.not_, (value,))
|
|
398
|
+
return not value
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _plain_bools(obj: Any) -> Any:
|
|
402
|
+
"""Replace traced booleans by real ``bool`` values (deeply), for external code."""
|
|
403
|
+
if isinstance(obj, TracedBool):
|
|
404
|
+
return obj._rt_node.value
|
|
405
|
+
kind = type(obj)
|
|
406
|
+
if kind is list:
|
|
407
|
+
return [_plain_bools(item) for item in obj]
|
|
408
|
+
if kind is tuple:
|
|
409
|
+
return tuple(_plain_bools(item) for item in obj)
|
|
410
|
+
if kind is dict:
|
|
411
|
+
return {k: _plain_bools(v) for k, v in obj.items()}
|
|
412
|
+
return obj
|
|
373
413
|
|
|
374
414
|
|
|
375
415
|
def _identity(value: Any) -> Any:
|
|
@@ -415,6 +455,7 @@ class _Runtime:
|
|
|
415
455
|
getitem = staticmethod(getitem)
|
|
416
456
|
key = staticmethod(key)
|
|
417
457
|
fstring = staticmethod(fstring_)
|
|
458
|
+
not_ = staticmethod(not_)
|
|
418
459
|
slice = slice
|
|
419
460
|
|
|
420
461
|
|
|
@@ -447,6 +488,8 @@ _COMPARE_NAMES: dict[type[ast.cmpop], str] = {
|
|
|
447
488
|
ast.NotEq: "ne",
|
|
448
489
|
ast.In: "in",
|
|
449
490
|
ast.NotIn: "not in",
|
|
491
|
+
ast.Is: "is",
|
|
492
|
+
ast.IsNot: "is not",
|
|
450
493
|
}
|
|
451
494
|
# Calls that depend on the calling frame must not be wrapped.
|
|
452
495
|
_FRAME_SENSITIVE = frozenset(
|
|
@@ -467,6 +510,10 @@ def _is_constant(node: ast.expr) -> bool:
|
|
|
467
510
|
return isinstance(node, ast.Constant)
|
|
468
511
|
|
|
469
512
|
|
|
513
|
+
def _is_bool_constant(node: ast.expr) -> bool:
|
|
514
|
+
return isinstance(node, ast.Constant) and type(node.value) is bool
|
|
515
|
+
|
|
516
|
+
|
|
470
517
|
class _Instrumenter(ast.NodeTransformer):
|
|
471
518
|
def __init__(self) -> None:
|
|
472
519
|
self._temps = itertools.count()
|
|
@@ -500,6 +547,12 @@ class _Instrumenter(ast.NodeTransformer):
|
|
|
500
547
|
return node
|
|
501
548
|
return ast.copy_location(_rt_call("binop", ast.Constant(name), node.left, node.right), node)
|
|
502
549
|
|
|
550
|
+
def visit_UnaryOp(self, node: ast.UnaryOp) -> ast.expr:
|
|
551
|
+
self.generic_visit(node)
|
|
552
|
+
if isinstance(node.op, ast.Not) and not _is_constant(node.operand):
|
|
553
|
+
return ast.copy_location(_rt_call("not_", node.operand), node)
|
|
554
|
+
return node
|
|
555
|
+
|
|
503
556
|
def visit_Compare(self, node: ast.Compare) -> ast.expr:
|
|
504
557
|
self.generic_visit(node)
|
|
505
558
|
if len(node.ops) != 1:
|
|
@@ -507,6 +560,10 @@ class _Instrumenter(ast.NodeTransformer):
|
|
|
507
560
|
name = _COMPARE_NAMES.get(type(node.ops[0]))
|
|
508
561
|
if name is None or (_is_constant(node.left) and _is_constant(node.comparators[0])):
|
|
509
562
|
return node
|
|
563
|
+
if name in ("is", "is not") and not (
|
|
564
|
+
_is_bool_constant(node.left) or _is_bool_constant(node.comparators[0])
|
|
565
|
+
):
|
|
566
|
+
return node # only `x is True/False` can differ for traced values
|
|
510
567
|
call = _rt_call("compare", ast.Constant(name), node.left, node.comparators[0])
|
|
511
568
|
return ast.copy_location(call, node)
|
|
512
569
|
|
|
@@ -317,6 +317,9 @@ def _register_builtins() -> None:
|
|
|
317
317
|
for name, fn in FUNCTION_OPS.items():
|
|
318
318
|
register_op(name, fn)
|
|
319
319
|
register_op("decimal", Decimal, symbol="Decimal")
|
|
320
|
+
register_op("is", operator.is_, symbol="is", style="infix")
|
|
321
|
+
register_op("is not", operator.is_not, symbol="is not", style="infix")
|
|
322
|
+
register_op("not", operator.not_, symbol="not ", style="prefix")
|
|
320
323
|
register_op("getitem", operator.getitem, style="getitem")
|
|
321
324
|
register_op("contains", operator.contains, symbol="in", style="contains")
|
|
322
325
|
for prefix, owner, methods in (
|
|
@@ -186,6 +186,7 @@ def trace_to_dict(trace: Trace) -> dict[str, Any]:
|
|
|
186
186
|
"version": VERSION,
|
|
187
187
|
"id": trace.id,
|
|
188
188
|
"name": trace.name,
|
|
189
|
+
"bools": trace.bools,
|
|
189
190
|
"decimal_context": _context_to_dict(trace.decimal_context),
|
|
190
191
|
"decimal_contexts": [_context_to_dict(ctx) for ctx in trace.decimal_contexts],
|
|
191
192
|
"nodes": nodes,
|
|
@@ -208,7 +209,7 @@ def trace_from_dict(data: Mapping[str, Any]) -> Trace:
|
|
|
208
209
|
if data.get("version") != VERSION:
|
|
209
210
|
raise ValueError(f"unsupported trace version {data.get('version')!r}; expected {VERSION}")
|
|
210
211
|
|
|
211
|
-
trace = Trace(data.get("name"), id=data["id"])
|
|
212
|
+
trace = Trace(data.get("name"), id=data["id"], bools=data.get("bools", False))
|
|
212
213
|
trace.decimal_context = _context_from_dict(data["decimal_context"])
|
|
213
214
|
trace._contexts = [_context_from_dict(ctx) for ctx in data.get("decimal_contexts", [])]
|
|
214
215
|
nodes: list[Node] = trace._nodes
|
|
@@ -71,11 +71,25 @@ class Trace:
|
|
|
71
71
|
trace.propagate({"ext:a": 900}) # what changes if an input does
|
|
72
72
|
|
|
73
73
|
A trace is not thread-safe: record into it from one thread at a time.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
name: A label for the trace.
|
|
77
|
+
id: Unique id; a random one by default.
|
|
78
|
+
bools: Trace booleans too. Boolean inputs become editable inputs, and
|
|
79
|
+
comparisons return traced booleans (:class:`~redroot.TracedBool`)
|
|
80
|
+
that are only guarded where their truth value is used, so flags
|
|
81
|
+
that are stored or computed with propagate exactly. Traced
|
|
82
|
+
booleans are ``int`` subclasses (Python forbids subclassing
|
|
83
|
+
``bool``): best used with :mod:`redroot.instrument`, and checked
|
|
84
|
+
with :func:`redroot.validation.check_fidelity`.
|
|
74
85
|
"""
|
|
75
86
|
|
|
76
|
-
def __init__(
|
|
87
|
+
def __init__(
|
|
88
|
+
self, name: str | None = None, *, id: str | None = None, bools: bool = False
|
|
89
|
+
) -> None:
|
|
77
90
|
self.id = id or uuid.uuid4().hex
|
|
78
91
|
self.name = name
|
|
92
|
+
self._bools = bools
|
|
79
93
|
self.decimal_context: decimal.Context = decimal.getcontext().copy()
|
|
80
94
|
"""Decimal context used for re-evaluation; captured when recording ends."""
|
|
81
95
|
self._nodes: list[Node] = []
|
|
@@ -125,6 +139,11 @@ class Trace:
|
|
|
125
139
|
"""Every node, in recording (topological) order. Do not mutate."""
|
|
126
140
|
return self._nodes
|
|
127
141
|
|
|
142
|
+
@property
|
|
143
|
+
def bools(self) -> bool:
|
|
144
|
+
"""Whether this trace traces booleans (see the ``bools`` argument)."""
|
|
145
|
+
return self._bools
|
|
146
|
+
|
|
128
147
|
@property
|
|
129
148
|
def decimal_contexts(self) -> Sequence[decimal.Context]:
|
|
130
149
|
"""The ``decimal`` contexts that ``Decimal`` nodes were computed under."""
|
|
@@ -176,23 +195,30 @@ class Trace:
|
|
|
176
195
|
"""Record ``value`` as an input and return its traced form.
|
|
177
196
|
|
|
178
197
|
Args:
|
|
179
|
-
value: An ``int``, ``float``, ``Decimal`` or ``str
|
|
198
|
+
value: An ``int``, ``float``, ``Decimal`` or ``str`` (or a
|
|
199
|
+
``bool`` if the trace traces booleans).
|
|
180
200
|
key: Semantic id used to refer to the input, e.g. for edits.
|
|
181
201
|
meta: Free-form metadata stored on the node.
|
|
182
202
|
"""
|
|
183
203
|
plain = unwrap(value)
|
|
184
|
-
factory =
|
|
204
|
+
factory = self._factory(plain)
|
|
185
205
|
if factory is None:
|
|
186
206
|
raise TypeError(f"cannot trace a value of type {type(plain).__name__}")
|
|
187
207
|
return factory(plain, self._add_leaf(plain, key, meta))
|
|
188
208
|
|
|
209
|
+
def _factory(self, plain: Any) -> Callable[[Any, Node], Any] | None:
|
|
210
|
+
if type(plain) is bool and not self._bools:
|
|
211
|
+
return None
|
|
212
|
+
return _WRAPPERS.get(type(plain))
|
|
213
|
+
|
|
189
214
|
def track(self, data: T, root: str = "in") -> T:
|
|
190
215
|
"""Return a copy of ``data`` whose scalar values are traced inputs.
|
|
191
216
|
|
|
192
217
|
Dicts, lists and tuples are copied; every ``int``, ``float``,
|
|
193
218
|
``Decimal`` and ``str`` inside becomes an input keyed by its path,
|
|
194
|
-
e.g. ``"ext:bank.line_items[3].amount"
|
|
195
|
-
|
|
219
|
+
e.g. ``"ext:bank.line_items[3].amount"``, as does every ``bool`` if
|
|
220
|
+
the trace traces booleans. Other values (``None``, objects, and
|
|
221
|
+
``bool`` otherwise) are kept as-is and listed in
|
|
196
222
|
:attr:`untracked_inputs`: editing them requires re-execution.
|
|
197
223
|
"""
|
|
198
224
|
return self._track(data, format_key(root)) # type: ignore[no-any-return]
|
|
@@ -206,7 +232,7 @@ class Trace:
|
|
|
206
232
|
if kind is tuple:
|
|
207
233
|
return tuple(self._track(v, child_key(key, i)) for i, v in enumerate(value))
|
|
208
234
|
plain = unwrap(value)
|
|
209
|
-
factory =
|
|
235
|
+
factory = self._factory(plain)
|
|
210
236
|
if factory is not None:
|
|
211
237
|
return factory(plain, self._add_leaf(plain, key, None))
|
|
212
238
|
self._check_new_key(key)
|
|
@@ -380,36 +406,42 @@ def _is_pydantic_model(value: Any) -> bool:
|
|
|
380
406
|
|
|
381
407
|
|
|
382
408
|
def _render(node: Node, depth: int, top: bool = False) -> str:
|
|
409
|
+
"""Render ``node`` as an expression; ``top`` omits the outer parentheses."""
|
|
383
410
|
if node.kind == LEAF:
|
|
384
411
|
return node.key if node.key is not None else repr(node.value)
|
|
385
412
|
if depth <= 0:
|
|
386
413
|
return f"#{node.id}"
|
|
387
|
-
args = [_render_arg(arg, depth - 1) for arg in node.args]
|
|
388
|
-
args += [f"{k}={_render_arg(v, depth - 1)}" for k, v in (node.kwargs or {}).items()]
|
|
389
414
|
if node.op == "fstring":
|
|
390
415
|
return _render_fstring(node.args, depth - 1)
|
|
391
|
-
if node.op == "getslice" and len(args) == 4:
|
|
392
|
-
start, stop, step = (
|
|
393
|
-
"" if a is None else t for a, t in zip(node.args[1:], args[1:], strict=True)
|
|
394
|
-
)
|
|
395
|
-
return f"{args[0]}[{start}:{stop}{':' + step if step else ''}]"
|
|
396
416
|
spec = get_op(node.op)
|
|
397
417
|
style = spec.style if spec is not None else "call"
|
|
398
418
|
symbol = spec.symbol if spec is not None else None
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return f"{
|
|
412
|
-
|
|
419
|
+
sub = depth - 1
|
|
420
|
+
|
|
421
|
+
def operand(arg: Any) -> str: # inside an operator expression: parenthesize
|
|
422
|
+
return _render_arg(arg, sub, top=False)
|
|
423
|
+
|
|
424
|
+
def argument(arg: Any) -> str: # between commas or brackets: no parentheses needed
|
|
425
|
+
return _render_arg(arg, sub, top=True)
|
|
426
|
+
|
|
427
|
+
keywords = [f"{k}={argument(v)}" for k, v in (node.kwargs or {}).items()]
|
|
428
|
+
first, rest = (node.args[0], node.args[1:]) if node.args else (None, ())
|
|
429
|
+
if node.op == "getslice" and len(node.args) == 4:
|
|
430
|
+
start, stop, step = ("" if a is None else argument(a) for a in rest)
|
|
431
|
+
return f"{operand(first)}[{start}:{stop}{':' + step if step else ''}]"
|
|
432
|
+
if style == "infix" and len(node.args) == 2 and not keywords:
|
|
433
|
+
text = f"{operand(first)} {symbol} {operand(rest[0])}"
|
|
434
|
+
return text if top else f"({text})"
|
|
435
|
+
if style == "contains" and len(node.args) == 2:
|
|
436
|
+
text = f"{operand(rest[0])} in {operand(first)}"
|
|
437
|
+
return text if top else f"({text})"
|
|
438
|
+
if style == "prefix" and len(node.args) == 1:
|
|
439
|
+
return f"{symbol}{operand(first)}"
|
|
440
|
+
if style == "getitem" and len(node.args) == 2:
|
|
441
|
+
return f"{operand(first)}[{argument(rest[0])}]"
|
|
442
|
+
if style == "method" and node.args:
|
|
443
|
+
return f"{operand(first)}.{symbol}({', '.join([*map(argument, rest), *keywords])})"
|
|
444
|
+
return f"{symbol or node.op}({', '.join([*map(argument, node.args), *keywords])})"
|
|
413
445
|
|
|
414
446
|
|
|
415
447
|
def _render_fstring(parts: tuple[Any, ...], depth: int) -> str:
|
|
@@ -420,22 +452,26 @@ def _render_fstring(parts: tuple[Any, ...], depth: int) -> str:
|
|
|
420
452
|
continue
|
|
421
453
|
value, conversion, spec = part
|
|
422
454
|
conv = f"!{conversion}" if conversion else ""
|
|
423
|
-
spec_text =
|
|
424
|
-
|
|
455
|
+
spec_text = (
|
|
456
|
+
f":{_render_arg(spec, depth, True) if type(spec) is Node else spec}" if spec else ""
|
|
457
|
+
)
|
|
458
|
+
out.append(f"{{{_render_arg(value, depth, True)}{conv}{spec_text}}}")
|
|
425
459
|
return 'f"' + "".join(out).replace('"', '\\"') + '"'
|
|
426
460
|
|
|
427
461
|
|
|
428
|
-
def _render_arg(arg: Any, depth: int) -> str:
|
|
462
|
+
def _render_arg(arg: Any, depth: int, top: bool = False) -> str:
|
|
429
463
|
kind = type(arg)
|
|
430
464
|
if kind is Node:
|
|
431
|
-
return _render(arg, depth)
|
|
465
|
+
return _render(arg, depth, top)
|
|
432
466
|
if kind is list:
|
|
433
|
-
return "[" + ", ".join(_render_arg(a, depth) for a in arg) + "]"
|
|
467
|
+
return "[" + ", ".join(_render_arg(a, depth, True) for a in arg) + "]"
|
|
434
468
|
if kind is tuple:
|
|
435
|
-
inner = ", ".join(_render_arg(a, depth) for a in arg)
|
|
469
|
+
inner = ", ".join(_render_arg(a, depth, True) for a in arg)
|
|
436
470
|
return f"({inner},)" if len(arg) == 1 else f"({inner})"
|
|
437
471
|
if kind is dict:
|
|
438
|
-
return
|
|
472
|
+
return (
|
|
473
|
+
"{" + ", ".join(f"{k!r}: {_render_arg(v, depth, True)}" for k, v in arg.items()) + "}"
|
|
474
|
+
)
|
|
439
475
|
return repr(arg)
|
|
440
476
|
|
|
441
477
|
|
|
@@ -459,16 +495,20 @@ def run(
|
|
|
459
495
|
input_root: str = "in",
|
|
460
496
|
output_root: str = "out",
|
|
461
497
|
name: str | None = None,
|
|
498
|
+
bools: bool = False,
|
|
462
499
|
) -> tuple[Trace, Any]:
|
|
463
500
|
"""Run ``workflow(inputs)`` under a new trace.
|
|
464
501
|
|
|
465
502
|
Inputs are traced under ``input_root`` and every value in the returned
|
|
466
|
-
result is collected as an output under ``output_root``.
|
|
503
|
+
result is collected as an output under ``output_root``. ``bools`` is
|
|
504
|
+
passed to :class:`Trace`.
|
|
467
505
|
|
|
468
506
|
Returns:
|
|
469
|
-
The trace and the workflow's result
|
|
507
|
+
The trace and the workflow's result (which holds traced values; use
|
|
508
|
+
:meth:`Trace.output_values` or :func:`redroot.unwrap_deep` for plain
|
|
509
|
+
ones).
|
|
470
510
|
"""
|
|
471
|
-
with Trace(name) as trace:
|
|
511
|
+
with Trace(name, bools=bools) as trace:
|
|
472
512
|
result = workflow(trace.track(inputs, input_root))
|
|
473
513
|
trace.collect(result, output_root)
|
|
474
514
|
return trace, result
|
|
@@ -38,6 +38,7 @@ from redroot._core import (
|
|
|
38
38
|
add_node,
|
|
39
39
|
binary,
|
|
40
40
|
call,
|
|
41
|
+
compare,
|
|
41
42
|
decimal_context_id,
|
|
42
43
|
observe_value,
|
|
43
44
|
record_raise,
|
|
@@ -60,7 +61,7 @@ if TYPE_CHECKING:
|
|
|
60
61
|
from pydantic import GetCoreSchemaHandler
|
|
61
62
|
from pydantic_core import CoreSchema
|
|
62
63
|
|
|
63
|
-
__all__ = ["TracedDecimal", "TracedFloat", "TracedInt", "TracedStr"]
|
|
64
|
+
__all__ = ["TracedBool", "TracedDecimal", "TracedFloat", "TracedInt", "TracedStr"]
|
|
64
65
|
|
|
65
66
|
|
|
66
67
|
def _construct(
|
|
@@ -70,6 +71,7 @@ def _construct(
|
|
|
70
71
|
value: Any,
|
|
71
72
|
key: str | None,
|
|
72
73
|
meta: dict[str, Any] | None,
|
|
74
|
+
alloc: type[Any] | None = None,
|
|
73
75
|
) -> Any:
|
|
74
76
|
"""Shared constructor logic.
|
|
75
77
|
|
|
@@ -95,7 +97,7 @@ def _construct(
|
|
|
95
97
|
node = add_node(trace, cast_op, (source,), None, plain, OP, meta=meta, ctx=ctx)
|
|
96
98
|
else:
|
|
97
99
|
node = trace._add_leaf(plain, key, meta)
|
|
98
|
-
obj: Any = base.__new__(cls, plain)
|
|
100
|
+
obj: Any = (alloc or base).__new__(cls, plain)
|
|
99
101
|
obj._rt_node = node
|
|
100
102
|
return obj
|
|
101
103
|
|
|
@@ -139,6 +141,7 @@ class _TracedScalar(Traced):
|
|
|
139
141
|
float: core_schema.float_schema,
|
|
140
142
|
Decimal: core_schema.decimal_schema,
|
|
141
143
|
str: core_schema.str_schema,
|
|
144
|
+
bool: core_schema.bool_schema,
|
|
142
145
|
}
|
|
143
146
|
from_plain = core_schema.no_info_after_validator_function(cls, schemas[cls._base]())
|
|
144
147
|
return core_schema.json_or_python_schema(
|
|
@@ -190,6 +193,27 @@ class TracedDecimal(_TracedScalar, Decimal):
|
|
|
190
193
|
return _construct(cls, Decimal, "decimal", value, key, meta) # type: ignore[no-any-return]
|
|
191
194
|
|
|
192
195
|
|
|
196
|
+
class TracedBool(_TracedScalar, int):
|
|
197
|
+
"""A boolean that records how it was computed.
|
|
198
|
+
|
|
199
|
+
Python does not allow subclassing ``bool``, so this is an ``int`` (as
|
|
200
|
+
``bool`` itself is) that prints, formats, hashes and compares like
|
|
201
|
+
``True``/``False``. Unlike a real ``bool``, ``isinstance(x, bool)`` and
|
|
202
|
+
``x is True`` are false, and ``json`` writes it as ``1``/``0``; code run
|
|
203
|
+
through :mod:`redroot.instrument` gets those right. Traced booleans are
|
|
204
|
+
only produced by traces created with ``bools=True``.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
# int is variable-size: instances keep the node in __dict__.
|
|
208
|
+
_base = bool
|
|
209
|
+
|
|
210
|
+
def __new__(
|
|
211
|
+
cls, value: Any = False, *, key: str | None = None, meta: dict[str, Any] | None = None
|
|
212
|
+
) -> TracedBool:
|
|
213
|
+
"""Convert ``value`` like ``bool(value)`` (see the module docstring)."""
|
|
214
|
+
return _construct(cls, bool, "bool", value, key, meta, alloc=int) # type: ignore[no-any-return]
|
|
215
|
+
|
|
216
|
+
|
|
193
217
|
class TracedStr(_TracedScalar, str):
|
|
194
218
|
"""A ``str`` that records how it was computed."""
|
|
195
219
|
|
|
@@ -237,10 +261,10 @@ def _rpow(self: Any, other: Any, modulo: Any = None) -> Any:
|
|
|
237
261
|
def _comparison(op: str) -> Callable[..., Any]:
|
|
238
262
|
fn = COMPARISON_OPS[op][0]
|
|
239
263
|
|
|
240
|
-
def
|
|
241
|
-
return
|
|
264
|
+
def method(self: Any, other: Any) -> Any:
|
|
265
|
+
return compare(op, fn, self, other)
|
|
242
266
|
|
|
243
|
-
return
|
|
267
|
+
return method
|
|
244
268
|
|
|
245
269
|
|
|
246
270
|
def _guard(op: str, fn: Callable[[Any], Any]) -> Callable[..., Any]:
|
|
@@ -318,15 +342,16 @@ def _install_number(
|
|
|
318
342
|
|
|
319
343
|
_ARITHMETIC = ("add", "sub", "mul", "truediv", "floordiv", "mod")
|
|
320
344
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
)
|
|
324
|
-
_install(
|
|
345
|
+
_INTEGER = (*_ARITHMETIC, "lshift", "rshift", "and", "or", "xor")
|
|
346
|
+
for _integer_cls in (TracedInt, TracedBool):
|
|
347
|
+
_install_number(_integer_cls, _INTEGER, ("neg", "pos", "invert"))
|
|
348
|
+
_install(_integer_cls, "__index__", _guard("index", operator.index))
|
|
325
349
|
_install_number(TracedFloat, _ARITHMETIC, ("neg", "pos"))
|
|
326
350
|
_install_number(TracedDecimal, _ARITHMETIC, ("neg", "pos"))
|
|
327
351
|
|
|
328
352
|
for _prefix, _cls, _owner, _names in (
|
|
329
353
|
("int", TracedInt, int, INT_METHODS),
|
|
354
|
+
("int", TracedBool, int, INT_METHODS),
|
|
330
355
|
("float", TracedFloat, float, FLOAT_METHODS),
|
|
331
356
|
("decimal", TracedDecimal, Decimal, DECIMAL_METHODS),
|
|
332
357
|
("str", TracedStr, str, STR_METHODS),
|
|
@@ -369,7 +394,7 @@ _install(TracedStr, "__getitem__", _str_getitem)
|
|
|
369
394
|
_install(
|
|
370
395
|
TracedStr,
|
|
371
396
|
"__contains__",
|
|
372
|
-
lambda self, item:
|
|
397
|
+
lambda self, item: compare("contains", operator.contains, self, item),
|
|
373
398
|
)
|
|
374
399
|
_install(TracedStr, "__len__", _guard("len", len))
|
|
375
400
|
_install(TracedStr, "__bool__", _guard("bool", bool))
|
|
@@ -383,3 +408,5 @@ register_wrapper(int, _factory(TracedInt, int))
|
|
|
383
408
|
register_wrapper(float, _factory(TracedFloat, float))
|
|
384
409
|
register_wrapper(Decimal, _factory(TracedDecimal, Decimal))
|
|
385
410
|
register_wrapper(str, _factory(TracedStr, str))
|
|
411
|
+
# Used only by traces created with bools=True (see record_result).
|
|
412
|
+
register_wrapper(bool, _factory(TracedBool, int))
|
|
@@ -8,10 +8,14 @@ Run these on real recorded workflows before relying on propagation:
|
|
|
8
8
|
operands reproduces the recorded value exactly.
|
|
9
9
|
* :func:`check_perturbation` - after an edit, propagated outputs agree with a
|
|
10
10
|
real re-execution wherever propagation claimed to know the answer.
|
|
11
|
+
* :func:`check_fidelity` - running under tracing produces exactly the
|
|
12
|
+
outputs the plain code produces. (Perturbation checks compare traced runs
|
|
13
|
+
with traced runs, so they cannot see a difference tracing itself makes.)
|
|
11
14
|
"""
|
|
12
15
|
|
|
13
16
|
from __future__ import annotations
|
|
14
17
|
|
|
18
|
+
import copy
|
|
15
19
|
import decimal
|
|
16
20
|
from collections import deque
|
|
17
21
|
from collections.abc import Callable, Mapping
|
|
@@ -25,9 +29,12 @@ from redroot.trace import Trace, run
|
|
|
25
29
|
|
|
26
30
|
__all__ = [
|
|
27
31
|
"Coverage",
|
|
32
|
+
"FidelityMismatch",
|
|
33
|
+
"FidelityReport",
|
|
28
34
|
"IdentityMismatch",
|
|
29
35
|
"IdentityReport",
|
|
30
36
|
"PerturbationReport",
|
|
37
|
+
"check_fidelity",
|
|
31
38
|
"check_identity",
|
|
32
39
|
"check_perturbation",
|
|
33
40
|
"coverage",
|
|
@@ -205,6 +212,7 @@ def check_perturbation(
|
|
|
205
212
|
*,
|
|
206
213
|
input_root: str = "in",
|
|
207
214
|
output_root: str = "out",
|
|
215
|
+
bools: bool = False,
|
|
208
216
|
) -> PerturbationReport:
|
|
209
217
|
"""Edit inputs, then compare graph propagation with a real re-execution.
|
|
210
218
|
|
|
@@ -213,9 +221,66 @@ def check_perturbation(
|
|
|
213
221
|
``UNCHANGED`` or ``UNLINKED`` must match the re-execution; ``STALE``
|
|
214
222
|
outputs are expected to need it.
|
|
215
223
|
"""
|
|
216
|
-
trace, _ = run(workflow, inputs, input_root=input_root, output_root=output_root)
|
|
224
|
+
trace, _ = run(workflow, inputs, input_root=input_root, output_root=output_root, bools=bools)
|
|
217
225
|
propagation = trace.propagate(edits)
|
|
218
226
|
edited = apply_edits(inputs, edits, root=input_root)
|
|
219
|
-
retrace, _ = run(workflow, edited, input_root=input_root, output_root=output_root)
|
|
227
|
+
retrace, _ = run(workflow, edited, input_root=input_root, output_root=output_root, bools=bools)
|
|
220
228
|
verification = propagation.verify(retrace.output_values())
|
|
221
229
|
return PerturbationReport(trace, propagation, retrace, verification)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True, slots=True)
|
|
233
|
+
class FidelityMismatch:
|
|
234
|
+
"""An output whose value differs between plain and traced execution."""
|
|
235
|
+
|
|
236
|
+
key: str
|
|
237
|
+
plain: Any
|
|
238
|
+
traced: Any
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True, slots=True)
|
|
242
|
+
class FidelityReport:
|
|
243
|
+
"""Result of :func:`check_fidelity`."""
|
|
244
|
+
|
|
245
|
+
mismatches: tuple[FidelityMismatch, ...]
|
|
246
|
+
missing: tuple[str, ...] = ()
|
|
247
|
+
"""Outputs the plain run produced but the traced run did not."""
|
|
248
|
+
unexpected: tuple[str, ...] = ()
|
|
249
|
+
"""Outputs the traced run produced but the plain run did not."""
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def ok(self) -> bool:
|
|
253
|
+
"""Whether tracing left every output exactly as the plain code computes it."""
|
|
254
|
+
return not (self.mismatches or self.missing or self.unexpected)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def check_fidelity(
|
|
258
|
+
workflow: Callable[[Any], Any],
|
|
259
|
+
inputs: Any,
|
|
260
|
+
*,
|
|
261
|
+
input_root: str = "in",
|
|
262
|
+
output_root: str = "out",
|
|
263
|
+
bools: bool = False,
|
|
264
|
+
) -> FidelityReport:
|
|
265
|
+
"""Run ``workflow`` plainly and under tracing, and compare their outputs.
|
|
266
|
+
|
|
267
|
+
Tracing must not change what code computes. This catches the places where
|
|
268
|
+
it does, e.g. ``json.dumps`` writing a traced boolean as ``1`` in code that
|
|
269
|
+
is not instrumented, or ``sum()`` adding floats without CPython's
|
|
270
|
+
compensated summation.
|
|
271
|
+
"""
|
|
272
|
+
plain_result = workflow(copy.deepcopy(inputs))
|
|
273
|
+
plain = Trace()
|
|
274
|
+
plain.collect(plain_result, output_root)
|
|
275
|
+
expected = plain.output_values()
|
|
276
|
+
trace, _ = run(workflow, inputs, input_root=input_root, output_root=output_root, bools=bools)
|
|
277
|
+
actual = trace.output_values()
|
|
278
|
+
return FidelityReport(
|
|
279
|
+
mismatches=tuple(
|
|
280
|
+
FidelityMismatch(key, expected[key], actual[key])
|
|
281
|
+
for key in expected
|
|
282
|
+
if key in actual and not same_value(expected[key], actual[key])
|
|
283
|
+
),
|
|
284
|
+
missing=tuple(key for key in expected if key not in actual),
|
|
285
|
+
unexpected=tuple(key for key in actual if key not in expected),
|
|
286
|
+
)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Boolean tracing (``Trace(bools=True)``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import operator
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import redroot as rr
|
|
13
|
+
from redroot import ReasonKind, Status, Trace, TracedBool, TracedInt, node_of, unwrap
|
|
14
|
+
from redroot._core import same_value
|
|
15
|
+
from redroot.instrument import exec_source
|
|
16
|
+
from redroot.validation import check_fidelity, check_perturbation
|
|
17
|
+
|
|
18
|
+
from .helpers import assert_replays
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_off_by_default() -> None:
|
|
22
|
+
with Trace() as trace:
|
|
23
|
+
a = trace.leaf(5, "in:a")
|
|
24
|
+
result = a > 3
|
|
25
|
+
tracked = trace.track({"flag": True}, root="ext")
|
|
26
|
+
assert type(result) is bool
|
|
27
|
+
assert tracked["flag"] is True
|
|
28
|
+
assert dict(trace.untracked_inputs) == {"ext:flag": True}
|
|
29
|
+
with pytest.raises(TypeError):
|
|
30
|
+
trace.leaf(True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Traced booleans behave like bool wherever an int subclass can.
|
|
34
|
+
@pytest.mark.parametrize(
|
|
35
|
+
"fn",
|
|
36
|
+
[
|
|
37
|
+
repr,
|
|
38
|
+
str,
|
|
39
|
+
lambda b: format(b, ""),
|
|
40
|
+
lambda b: f"{b}!",
|
|
41
|
+
lambda b: b + 1,
|
|
42
|
+
lambda b: b * 10,
|
|
43
|
+
lambda b: b & True,
|
|
44
|
+
lambda b: b | False,
|
|
45
|
+
lambda b: b ^ True,
|
|
46
|
+
lambda b: -b,
|
|
47
|
+
lambda b: b == 1,
|
|
48
|
+
lambda b: b == True, # noqa: E712 - comparing with True is the point
|
|
49
|
+
lambda b: hash(b) == hash(True),
|
|
50
|
+
lambda b: {True: "yes"}.get(b),
|
|
51
|
+
lambda b: sum([b, b, True]),
|
|
52
|
+
lambda b: int(b),
|
|
53
|
+
lambda b: bool(b),
|
|
54
|
+
lambda b: "%d/%s" % (b, b), # noqa: UP031 - testing % itself
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
def test_traced_bool_matches_bool(fn: Any) -> None:
|
|
58
|
+
expected = fn(True)
|
|
59
|
+
with Trace(bools=True) as trace:
|
|
60
|
+
result = fn(trace.leaf(True, "in:b"))
|
|
61
|
+
assert same_value(unwrap(result), expected), (result, expected)
|
|
62
|
+
assert_replays(trace)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_known_differences_from_bool() -> None:
|
|
66
|
+
with Trace(bools=True) as trace:
|
|
67
|
+
b = trace.leaf(True, "in:b")
|
|
68
|
+
assert isinstance(b, int)
|
|
69
|
+
assert not isinstance(b, bool)
|
|
70
|
+
assert b is not True
|
|
71
|
+
assert json.dumps(b) == "1"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_comparisons_return_traced_booleans_guarded_only_when_used() -> None:
|
|
75
|
+
with Trace(bools=True) as trace:
|
|
76
|
+
income = trace.leaf(25000, "ext:income")
|
|
77
|
+
eligible = income > 30000
|
|
78
|
+
assert type(eligible) is TracedBool
|
|
79
|
+
assert trace.guards == [] # stored, not branched on
|
|
80
|
+
if eligible:
|
|
81
|
+
pass
|
|
82
|
+
(guard,) = trace.guards
|
|
83
|
+
assert trace.explain(guard) == "bool(ext:income > 30000)" # no doubled parentheses
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_flag_outputs_propagate_exactly() -> None:
|
|
87
|
+
def form(ext: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
eligible = ext["income"] > 30000
|
|
89
|
+
return {"eligible": eligible, "credit": 500 * eligible + 100 * ext["married"]}
|
|
90
|
+
|
|
91
|
+
trace, _ = rr.run(form, {"income": 25000, "married": True}, input_root="ext", bools=True)
|
|
92
|
+
assert trace.explain("out:credit") == "(500 * (ext:income > 30000)) + (100 * ext:married)"
|
|
93
|
+
result = trace.propagate({"ext:income": 40000})
|
|
94
|
+
assert result.exact
|
|
95
|
+
assert {k: (c.status, c.new) for k, c in result.outputs.items()} == {
|
|
96
|
+
"out:eligible": (Status.UPDATED, True),
|
|
97
|
+
"out:credit": (Status.UPDATED, 600),
|
|
98
|
+
}
|
|
99
|
+
married = trace.propagate({"ext:married": False})
|
|
100
|
+
assert married.exact
|
|
101
|
+
assert married.outputs["out:credit"].new == 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_branching_on_a_boolean_input_is_guarded() -> None:
|
|
105
|
+
trace, _ = rr.run(
|
|
106
|
+
lambda e: {"credit": 500 if e["married"] else 0},
|
|
107
|
+
{"married": True},
|
|
108
|
+
input_root="ext",
|
|
109
|
+
bools=True,
|
|
110
|
+
)
|
|
111
|
+
result = trace.propagate({"ext:married": False})
|
|
112
|
+
assert [r.kind for r in result.reasons] == [ReasonKind.GUARD_FLIPPED]
|
|
113
|
+
assert result.reasons[0].message == "bool(ext:married) was True, now False"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_string_predicates_and_membership_are_traced_booleans() -> None:
|
|
117
|
+
with Trace(bools=True) as trace:
|
|
118
|
+
s = trace.leaf("INV-2024", "in:s")
|
|
119
|
+
is_invoice = s.startswith("INV")
|
|
120
|
+
has_year = "2024" in s
|
|
121
|
+
assert type(is_invoice) is TracedBool
|
|
122
|
+
assert trace.propagate({"in:s": "REC-2024"}).value(is_invoice) is False
|
|
123
|
+
# Python's `in` always converts the result to a plain bool, so it is a
|
|
124
|
+
# guard: changing the outcome requires re-execution...
|
|
125
|
+
assert type(has_year) is bool
|
|
126
|
+
assert not trace.propagate({"in:s": "INV-2023"}).exact
|
|
127
|
+
# ...but instrumented code asks the container directly.
|
|
128
|
+
contains = exec_source("def f(s): return '2024' in s, '2024' not in s")["f"]
|
|
129
|
+
with Trace(bools=True) as trace:
|
|
130
|
+
found, missing = contains(trace.leaf("INV-2024", "in:s"))
|
|
131
|
+
assert (type(found), type(missing)) == (TracedBool, TracedBool)
|
|
132
|
+
assert trace.explain(missing) == "not ('2024' in in:s)"
|
|
133
|
+
assert trace.propagate({"in:s": "REC-2023"}).value(found) is False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_traced_bool_constructor_and_cast() -> None:
|
|
137
|
+
with Trace(bools=True) as trace:
|
|
138
|
+
n = trace.leaf(0, "in:n")
|
|
139
|
+
flag = TracedBool(n)
|
|
140
|
+
named = TracedBool(True, key="in:named")
|
|
141
|
+
assert unwrap(flag) is False
|
|
142
|
+
assert trace.explain(flag) == "bool(in:n)"
|
|
143
|
+
assert trace.inputs["in:named"] is node_of(named)
|
|
144
|
+
assert trace.propagate({"in:n": 3}).value(flag) is True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_outputs_are_plain_bools() -> None:
|
|
148
|
+
trace, result = rr.run(lambda e: {"ok": e["x"] > 1}, {"x": 5}, bools=True)
|
|
149
|
+
assert type(result["ok"]) is TracedBool
|
|
150
|
+
assert trace.output_values() == {"out:ok": True}
|
|
151
|
+
assert rr.unwrap_deep(result) == {"ok": True}
|
|
152
|
+
assert type(rr.unwrap_deep(result)["ok"]) is bool
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_serialization_keeps_the_option_and_boolean_inputs() -> None:
|
|
156
|
+
trace, _ = rr.run(
|
|
157
|
+
lambda e: {"x": e["a"] * e["flag"]}, {"a": 3, "flag": True}, input_root="ext", bools=True
|
|
158
|
+
)
|
|
159
|
+
restored = Trace.from_json(trace.to_json())
|
|
160
|
+
assert restored.bools
|
|
161
|
+
assert restored.propagate({"ext:flag": False}).outputs["out:x"].new == 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_pydantic_field() -> None:
|
|
165
|
+
pydantic = pytest.importorskip("pydantic")
|
|
166
|
+
|
|
167
|
+
class Form(pydantic.BaseModel): # type: ignore[name-defined, misc]
|
|
168
|
+
married: TracedBool
|
|
169
|
+
|
|
170
|
+
with Trace(bools=True) as trace:
|
|
171
|
+
flag = trace.leaf(True, "in:married")
|
|
172
|
+
form = Form(married=flag)
|
|
173
|
+
plain = Form(married=False)
|
|
174
|
+
assert form.married is flag
|
|
175
|
+
assert type(plain.married) is TracedBool
|
|
176
|
+
assert form.model_dump() == {"married": True}
|
|
177
|
+
assert form.model_dump_json() == '{"married":true}'
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_bools_do_not_leak_into_other_types() -> None:
|
|
181
|
+
with Trace(bools=True) as trace:
|
|
182
|
+
a = trace.leaf(5, "in:a")
|
|
183
|
+
total = a + (a > 3)
|
|
184
|
+
assert type(total) is TracedInt
|
|
185
|
+
assert unwrap(total) == 6
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --- instrumentation repairs the differences ---------------------------
|
|
189
|
+
|
|
190
|
+
SOURCE = """
|
|
191
|
+
import json
|
|
192
|
+
def main(ext):
|
|
193
|
+
ok = ext["income"] > 30000
|
|
194
|
+
return {
|
|
195
|
+
"json": json.dumps({"ok": ok, "married": ext["married"]}),
|
|
196
|
+
"is_false": ok is False,
|
|
197
|
+
"is_true": ext["married"] is True,
|
|
198
|
+
"isinstance": isinstance(ok, bool),
|
|
199
|
+
"type": type(ok) is bool,
|
|
200
|
+
"not": not ok,
|
|
201
|
+
}
|
|
202
|
+
"""
|
|
203
|
+
INPUTS = {"income": 25000, "married": True}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_instrumented_code_sees_real_booleans() -> None:
|
|
207
|
+
main = exec_source(SOURCE)["main"]
|
|
208
|
+
trace, _ = rr.run(main, INPUTS, input_root="ext", bools=True)
|
|
209
|
+
assert trace.output_values() == {
|
|
210
|
+
"out:json": '{"ok": false, "married": true}',
|
|
211
|
+
"out:is_false": True,
|
|
212
|
+
"out:is_true": True,
|
|
213
|
+
"out:isinstance": True,
|
|
214
|
+
"out:type": True,
|
|
215
|
+
"out:not": True,
|
|
216
|
+
}
|
|
217
|
+
assert trace.explain("out:not") == "not (ext:income > 30000)"
|
|
218
|
+
assert trace.explain("out:is_false") == "(ext:income > 30000) is False"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_check_fidelity_finds_differences_only_without_instrumentation() -> None:
|
|
222
|
+
namespace: dict[str, Any] = {}
|
|
223
|
+
exec(SOURCE, namespace) # noqa: S102
|
|
224
|
+
report = check_fidelity(namespace["main"], INPUTS, input_root="ext", bools=True)
|
|
225
|
+
assert {m.key for m in report.mismatches} == {
|
|
226
|
+
"out:json",
|
|
227
|
+
"out:is_false",
|
|
228
|
+
"out:is_true",
|
|
229
|
+
"out:isinstance",
|
|
230
|
+
"out:type",
|
|
231
|
+
}
|
|
232
|
+
assert check_fidelity(namespace["main"], INPUTS, input_root="ext").ok # bools off
|
|
233
|
+
assert check_fidelity(exec_source(SOURCE)["main"], INPUTS, input_root="ext", bools=True).ok
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.mark.skipif(sys.version_info < (3, 12), reason="compensated float sum() is new in 3.12")
|
|
237
|
+
def test_check_fidelity_finds_float_summation_differences() -> None:
|
|
238
|
+
def workflow(d: dict[str, Any]) -> dict[str, Any]:
|
|
239
|
+
return {"total": sum(d["xs"])}
|
|
240
|
+
|
|
241
|
+
report = check_fidelity(workflow, {"xs": [0.1] * 10})
|
|
242
|
+
assert [(m.key, m.plain) for m in report.mismatches] == [("out:total", 1.0)]
|
|
243
|
+
assert check_fidelity(
|
|
244
|
+
exec_source("def f(d): return {'total': sum(d['xs'])}")["f"], {"xs": [0.1] * 10}
|
|
245
|
+
).ok
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.mark.parametrize(
|
|
249
|
+
"edit", [{"ext:income": 40000}, {"ext:income": 26000}, {"ext:married": False}]
|
|
250
|
+
)
|
|
251
|
+
def test_instrumented_boolean_workflow_is_consistent(edit: dict[str, Any]) -> None:
|
|
252
|
+
source = """
|
|
253
|
+
def main(ext):
|
|
254
|
+
eligible = ext["income"] > 30000 and not ext["married"]
|
|
255
|
+
credit = 500 * eligible
|
|
256
|
+
if ext["income"] < 1000:
|
|
257
|
+
credit = 0
|
|
258
|
+
return {"eligible": eligible, "credit": credit}
|
|
259
|
+
"""
|
|
260
|
+
report = check_perturbation(
|
|
261
|
+
exec_source(source)["main"], INPUTS, edit, input_root="ext", bools=True
|
|
262
|
+
)
|
|
263
|
+
assert report.consistent, report.verification
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_comparison_ops_are_registered_for_identity() -> None:
|
|
267
|
+
from redroot.ops import get_op
|
|
268
|
+
|
|
269
|
+
assert get_op("is") is not None
|
|
270
|
+
assert get_op("is").fn is operator.is_ # type: ignore[union-attr]
|
|
271
|
+
assert get_op("not").fn is operator.not_ # type: ignore[union-attr]
|
|
@@ -253,6 +253,7 @@ def test_exec_source_uses_the_given_namespace() -> None:
|
|
|
253
253
|
|
|
254
254
|
PROGRAM_OPS = ["+", "-", "*", "/", "%"]
|
|
255
255
|
values = st.one_of(
|
|
256
|
+
st.booleans(),
|
|
256
257
|
st.integers(-20, 20),
|
|
257
258
|
st.floats(-100, 100, allow_nan=False).filter(lambda f: f == 0 or abs(f) > 1e-3),
|
|
258
259
|
st.decimals(-100, 100, places=2, allow_nan=False, allow_infinity=False),
|
|
@@ -275,10 +276,15 @@ def programs(draw: st.DrawFn) -> tuple[str, list[Any]]:
|
|
|
275
276
|
st.builds(lambda i: f"v[{i}]", st.integers(0, len(inputs) - 1)), values.map(repr)
|
|
276
277
|
)
|
|
277
278
|
)
|
|
278
|
-
|
|
279
|
+
shape = draw(st.sampled_from(["plain", "guard", "flag", "not"]))
|
|
279
280
|
expr = f"({left} {op} {right})"
|
|
280
|
-
if guard:
|
|
281
|
+
if shape == "guard":
|
|
281
282
|
expr = f"({expr} if {left} < {right} else {right})"
|
|
283
|
+
elif shape == "flag":
|
|
284
|
+
expr = f"({expr} * ({left} > {right}))" # a comparison used as data
|
|
285
|
+
elif shape == "not":
|
|
286
|
+
ref = f"v[{draw(st.integers(0, len(inputs) - 1))}]"
|
|
287
|
+
expr = f"({expr} + (not ({ref} is True)))"
|
|
282
288
|
lines.append(f" v.append({expr})")
|
|
283
289
|
lines.append(" return v[len(xs):]")
|
|
284
290
|
return "from decimal import Decimal\n" + "\n".join(lines), inputs
|
|
@@ -295,8 +301,9 @@ def test_instrumented_propagation_agrees_with_reexecution(
|
|
|
295
301
|
def workflow(d: dict[str, Any]) -> dict[str, Any]:
|
|
296
302
|
return {"v": f(d["xs"])}
|
|
297
303
|
|
|
304
|
+
bools = data.draw(st.booleans())
|
|
298
305
|
try:
|
|
299
|
-
trace, _ = run(workflow, {"xs": inputs})
|
|
306
|
+
trace, _ = run(workflow, {"xs": inputs}, bools=bools)
|
|
300
307
|
except (ArithmeticError, TypeError):
|
|
301
308
|
return
|
|
302
309
|
index = data.draw(st.integers(0, len(inputs) - 1))
|
|
@@ -305,7 +312,7 @@ def test_instrumented_propagation_agrees_with_reexecution(
|
|
|
305
312
|
edited = list(inputs)
|
|
306
313
|
edited[index] = new_value
|
|
307
314
|
try:
|
|
308
|
-
retrace, _ = run(workflow, {"xs": edited})
|
|
315
|
+
retrace, _ = run(workflow, {"xs": edited}, bools=bools)
|
|
309
316
|
except (ArithmeticError, TypeError):
|
|
310
317
|
assert not result.exact
|
|
311
318
|
return
|
|
@@ -28,7 +28,12 @@ decimals = st.decimals(
|
|
|
28
28
|
min_value=-1000, max_value=1000, places=2, allow_nan=False, allow_infinity=False
|
|
29
29
|
)
|
|
30
30
|
numbers = st.one_of(ints, floats, decimals)
|
|
31
|
-
STRATEGY_FOR: dict[type, st.SearchStrategy[Any]] = {
|
|
31
|
+
STRATEGY_FOR: dict[type, st.SearchStrategy[Any]] = {
|
|
32
|
+
int: ints,
|
|
33
|
+
float: floats,
|
|
34
|
+
Decimal: decimals,
|
|
35
|
+
bool: st.booleans(),
|
|
36
|
+
}
|
|
32
37
|
|
|
33
38
|
BINARY: dict[str, Callable[[Any, Any], Any]] = {
|
|
34
39
|
"+": operator.add,
|
|
@@ -89,25 +94,31 @@ STEP_OPS: dict[str, Callable[[Any, Any], Any]] = {
|
|
|
89
94
|
"max": lambda x, y: x if x > y else y, # data-dependent branch
|
|
90
95
|
"clamp": lambda x, c: x if x < c else c, # comparison with a constant
|
|
91
96
|
"sum": lambda x, y: sum([x, y, 1]),
|
|
97
|
+
"flag": lambda x, y: x * (x > y), # a comparison used as data
|
|
98
|
+
"either": lambda x, y: (x > y) or (y > x), # truth-tested operands
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
|
|
95
102
|
@st.composite
|
|
96
|
-
def programs(draw: st.DrawFn) -> tuple[list[Any], list[Step]]:
|
|
97
|
-
|
|
103
|
+
def programs(draw: st.DrawFn) -> tuple[list[Any], list[Step], bool]:
|
|
104
|
+
bools = draw(st.booleans())
|
|
105
|
+
kind = draw(st.sampled_from([ints, floats, decimals, st.booleans()]))
|
|
98
106
|
inputs = draw(st.lists(kind, min_size=1, max_size=4))
|
|
107
|
+
# Without boolean tracing, "either" yields plain bools, and a plain bool on
|
|
108
|
+
# the left of a traced int is a documented gap of uninstrumented code.
|
|
109
|
+
ops = sorted(STEP_OPS) if bools else sorted(set(STEP_OPS) - {"either"})
|
|
99
110
|
steps: list[Step] = []
|
|
100
111
|
for n in range(draw(st.integers(min_value=1, max_value=8))):
|
|
101
112
|
available = len(inputs) + n
|
|
102
113
|
steps.append(
|
|
103
114
|
(
|
|
104
|
-
draw(st.sampled_from(
|
|
115
|
+
draw(st.sampled_from(ops)),
|
|
105
116
|
draw(st.integers(0, available - 1)),
|
|
106
117
|
draw(st.integers(0, available - 1)),
|
|
107
118
|
draw(ints),
|
|
108
119
|
)
|
|
109
120
|
)
|
|
110
|
-
return inputs, steps
|
|
121
|
+
return inputs, steps, bools
|
|
111
122
|
|
|
112
123
|
|
|
113
124
|
def execute(steps: list[Step]) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
|
@@ -124,12 +135,12 @@ def execute(steps: list[Step]) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
|
|
124
135
|
@settings(max_examples=300, deadline=None)
|
|
125
136
|
@given(program=programs(), data=st.data())
|
|
126
137
|
def test_propagation_agrees_with_reexecution(
|
|
127
|
-
program: tuple[list[Any], list[Step]], data: st.DataObject
|
|
138
|
+
program: tuple[list[Any], list[Step], bool], data: st.DataObject
|
|
128
139
|
) -> None:
|
|
129
|
-
inputs, steps = program
|
|
140
|
+
inputs, steps, bools = program
|
|
130
141
|
workflow = execute(steps)
|
|
131
142
|
try:
|
|
132
|
-
trace, _ = run(workflow, {"x": inputs})
|
|
143
|
+
trace, _ = run(workflow, {"x": inputs}, bools=bools)
|
|
133
144
|
except (ArithmeticError, TypeError):
|
|
134
145
|
assume(False)
|
|
135
146
|
|
|
@@ -141,7 +152,7 @@ def test_propagation_agrees_with_reexecution(
|
|
|
141
152
|
edited = list(inputs)
|
|
142
153
|
edited[index] = new_value
|
|
143
154
|
try:
|
|
144
|
-
retrace, _ = run(workflow, {"x": edited})
|
|
155
|
+
retrace, _ = run(workflow, {"x": edited}, bools=bools)
|
|
145
156
|
except (ArithmeticError, TypeError):
|
|
146
157
|
# Re-execution fails: propagation must not have claimed exact results.
|
|
147
158
|
assert not result.exact
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|