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.
Files changed (46) hide show
  1. {redroot-0.2.0 → redroot-0.3.0}/CHANGELOG.md +18 -0
  2. {redroot-0.2.0 → redroot-0.3.0}/PKG-INFO +23 -2
  3. {redroot-0.2.0 → redroot-0.3.0}/README.md +22 -1
  4. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/__init__.py +5 -3
  5. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/_core.py +14 -1
  6. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/instrument.py +65 -8
  7. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/ops.py +3 -0
  8. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/serialization.py +2 -1
  9. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/trace.py +77 -37
  10. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/types.py +37 -10
  11. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/validation.py +67 -2
  12. redroot-0.3.0/tests/test_bools.py +271 -0
  13. {redroot-0.2.0 → redroot-0.3.0}/tests/test_instrument.py +11 -4
  14. {redroot-0.2.0 → redroot-0.3.0}/tests/test_properties.py +20 -9
  15. {redroot-0.2.0 → redroot-0.3.0}/.gitignore +0 -0
  16. {redroot-0.2.0 → redroot-0.3.0}/LICENSE +0 -0
  17. {redroot-0.2.0 → redroot-0.3.0}/pyproject.toml +0 -0
  18. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/cli.py +0 -0
  19. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/functions.py +0 -0
  20. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/paths.py +0 -0
  21. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/propagation.py +0 -0
  22. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/py.typed +0 -0
  23. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/__init__.py +0 -0
  24. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/data.py +0 -0
  25. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/graphviz.py +0 -0
  26. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/networkx.py +0 -0
  27. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/__init__.py +0 -0
  28. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/server.py +0 -0
  29. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/static/app.js +0 -0
  30. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/static/index.html +0 -0
  31. {redroot-0.2.0 → redroot-0.3.0}/src/redroot/visualizer/web/static/style.css +0 -0
  32. {redroot-0.2.0 → redroot-0.3.0}/tests/__init__.py +0 -0
  33. {redroot-0.2.0 → redroot-0.3.0}/tests/conftest.py +0 -0
  34. {redroot-0.2.0 → redroot-0.3.0}/tests/helpers.py +0 -0
  35. {redroot-0.2.0 → redroot-0.3.0}/tests/test_cli.py +0 -0
  36. {redroot-0.2.0 → redroot-0.3.0}/tests/test_docs.py +0 -0
  37. {redroot-0.2.0 → redroot-0.3.0}/tests/test_functions.py +0 -0
  38. {redroot-0.2.0 → redroot-0.3.0}/tests/test_paths.py +0 -0
  39. {redroot-0.2.0 → redroot-0.3.0}/tests/test_propagation.py +0 -0
  40. {redroot-0.2.0 → redroot-0.3.0}/tests/test_pydantic.py +0 -0
  41. {redroot-0.2.0 → redroot-0.3.0}/tests/test_regressions.py +0 -0
  42. {redroot-0.2.0 → redroot-0.3.0}/tests/test_serialization.py +0 -0
  43. {redroot-0.2.0 → redroot-0.3.0}/tests/test_trace.py +0 -0
  44. {redroot-0.2.0 → redroot-0.3.0}/tests/test_types.py +0 -0
  45. {redroot-0.2.0 → redroot-0.3.0}/tests/test_validation.py +0 -0
  46. {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.2.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
- - Only `int`, `float`, `Decimal` and `str` are traced. `bool` and `None` inputs are recorded as untracked: editing them requires re-execution.
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
- - Only `int`, `float`, `Decimal` and `str` are traced. `bool` and `None` inputs are recorded as untracked: editing them requires re-execution.
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.2.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
- trace = _core.active_trace()
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(*args, **kwargs)
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(*args, **kwargs)
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, recorded as a guard."""
377
+ """``left <op> right`` for a single comparison of traced values."""
365
378
  if op in ("in", "not in"):
366
- if isinstance(left, Traced) and not isinstance(right, Traced):
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
- return observe_value(op, COMPARISON_OPS[op][0], left, right)
372
- return COMPARISON_OPS[op][0](left, right)
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__(self, name: str | None = None, *, id: str | None = None) -> None:
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 = _WRAPPERS.get(type(plain))
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"``. Other values (``None``,
195
- ``bool``, objects) are kept as-is and listed in
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 = _WRAPPERS.get(type(plain))
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
- text: str
400
- if style == "infix" and len(args) == 2:
401
- text = f"{args[0]} {symbol} {args[1]}"
402
- elif style == "contains" and len(args) == 2:
403
- text = f"{args[1]} in {args[0]}"
404
- elif style == "prefix" and len(args) == 1:
405
- return f"{symbol}{args[0]}"
406
- elif style == "getitem" and len(args) == 2:
407
- return f"{args[0]}[{args[1]}]"
408
- elif style == "method" and args:
409
- return f"{args[0]}.{symbol}({', '.join(args[1:])})"
410
- else:
411
- return f"{symbol or node.op}({', '.join(args)})"
412
- return text if top else f"({text})"
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 = f":{_render_arg(spec, depth) if type(spec) is Node else spec}" if spec else ""
424
- out.append(f"{{{_render_arg(value, depth)}{conv}{spec_text}}}")
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 "{" + ", ".join(f"{k!r}: {_render_arg(v, depth)}" for k, v in arg.items()) + "}"
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 compare(self: Any, other: Any) -> Any:
241
- return observe_value(op, fn, self, other)
264
+ def method(self: Any, other: Any) -> Any:
265
+ return compare(op, fn, self, other)
242
266
 
243
- return compare
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
- _install_number(
322
- TracedInt, (*_ARITHMETIC, "lshift", "rshift", "and", "or", "xor"), ("neg", "pos", "invert")
323
- )
324
- _install(TracedInt, "__index__", _guard("index", operator.index))
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: observe_value("contains", operator.contains, 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
- guard = draw(st.booleans())
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]] = {int: ints, float: floats, Decimal: decimals}
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
- kind = draw(st.sampled_from([ints, floats, decimals]))
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(sorted(STEP_OPS))),
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