mstlo-python 0.2.1__tar.gz → 0.2.2__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 (74) hide show
  1. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/Cargo.lock +3 -3
  2. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/PKG-INFO +1 -1
  3. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/Cargo.toml +2 -2
  4. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/mstlo-macros/Cargo.toml +1 -1
  5. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/core.rs +0 -9
  6. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/operators/binary_operators.rs +135 -101
  7. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/operators/not_operator.rs +0 -5
  8. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/operators/unary_temporal_operators.rs +24 -100
  9. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/operators/until_operator.rs +10 -29
  10. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/ring_buffer.rs +1 -1
  11. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/fixtures/oracles.rs +11 -9
  12. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/monitor_dense_time_test.rs +19 -5
  13. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/monitor_multi_signal_test.rs +65 -0
  14. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/Cargo.toml +1 -1
  15. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/Cargo.toml +0 -0
  16. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/README.md +0 -0
  17. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/.gitignore +0 -0
  18. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/Cargo.lock +0 -0
  19. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/LICENSE +0 -0
  20. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/README.md +0 -0
  21. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/benches/ab_benchmark.rs +0 -0
  22. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/benches/incubator_benchmark.rs +0 -0
  23. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/benches/memory_profile_benchmark.rs +0 -0
  24. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/benches/monitor_benchmark.rs +0 -0
  25. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/benches/paper_benchmark.rs +0 -0
  26. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/examples/batch_update.rs +0 -0
  27. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/examples/csv_example.rs +0 -0
  28. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/examples/data/property.stl +0 -0
  29. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/examples/data/trace.csv +0 -0
  30. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/examples/intro_example.rs +0 -0
  31. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/examples/simple_example.rs +0 -0
  32. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/examples/variables_example.rs +0 -0
  33. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/mstlo-macros/Cargo.lock +0 -0
  34. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/mstlo-macros/LICENSE +0 -0
  35. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/mstlo-macros/README.md +0 -0
  36. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/mstlo-macros/src/lib.rs +0 -0
  37. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/formula_definition.rs +0 -0
  38. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/formulas.rs +0 -0
  39. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/interner.rs +0 -0
  40. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/lib.rs +0 -0
  41. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/monitor.rs +0 -0
  42. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/naive_operators.rs +0 -0
  43. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/operators/atomic_operators.rs +0 -0
  44. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/operators/mod.rs +0 -0
  45. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/parser.rs +0 -0
  46. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/src/synchronizer.rs +0 -0
  47. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/common/mod.rs +0 -0
  48. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/fixtures/formulas.rs +0 -0
  49. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/fixtures/mod.rs +0 -0
  50. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/fixtures/signals.rs +0 -0
  51. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/linear_interpolation_test.rs +0 -0
  52. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/macros_test.rs +0 -0
  53. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/monitor_build_fail_test.rs +0 -0
  54. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/monitor_rosi_test.rs +0 -0
  55. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/monitor_test.rs +0 -0
  56. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo/tests/parser_parity_test.rs +0 -0
  57. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/.gitignore +0 -0
  58. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/README.md +0 -0
  59. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/docs/index.md +0 -0
  60. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/docs/reference.md +0 -0
  61. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/examples/batch_update.py +0 -0
  62. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/examples/csv_example.py +0 -0
  63. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/examples/data/property.stl +0 -0
  64. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/examples/data/trace.csv +0 -0
  65. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/examples/semantics-comparison.ipynb +0 -0
  66. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/examples/simple-example.py +0 -0
  67. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/examples/variables_example.py +0 -0
  68. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/mkdocs.yml +0 -0
  69. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/src/lib.rs +0 -0
  70. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/tests/__init__.py +0 -0
  71. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/mstlo-python/tests/test_mstlo_python.py +0 -0
  72. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/pyproject.toml +0 -0
  73. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/python/mstlo_python/__init__.py +0 -0
  74. {mstlo_python-0.2.1 → mstlo_python-0.2.2}/python/mstlo_python/mstlo_python.pyi +0 -0
@@ -594,7 +594,7 @@ dependencies = [
594
594
 
595
595
  [[package]]
596
596
  name = "mstlo"
597
- version = "0.2.1"
597
+ version = "0.2.2"
598
598
  dependencies = [
599
599
  "criterion",
600
600
  "csv",
@@ -612,7 +612,7 @@ dependencies = [
612
612
 
613
613
  [[package]]
614
614
  name = "mstlo-macros"
615
- version = "0.2.1"
615
+ version = "0.2.2"
616
616
  dependencies = [
617
617
  "proc-macro2",
618
618
  "quote",
@@ -621,7 +621,7 @@ dependencies = [
621
621
 
622
622
  [[package]]
623
623
  name = "mstlo-python"
624
- version = "0.2.1"
624
+ version = "0.2.2"
625
625
  dependencies = [
626
626
  "mstlo",
627
627
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mstlo-python
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3.8
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "mstlo"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  edition = "2024"
5
5
  description = "A Rust library for online monitoring of Signal Temporal Logic (STL) specifications."
6
6
  license-file = "LICENSE"
@@ -13,7 +13,7 @@ categories = ["science", "mathematics"]
13
13
 
14
14
  [dependencies]
15
15
  dyn-clone = "1.0.20"
16
- mstlo-macros = { version = "0.2.1", path = "mstlo-macros" }
16
+ mstlo-macros = { version = "0.2.2", path = "mstlo-macros" }
17
17
 
18
18
  [dev-dependencies]
19
19
  proptest = "1.11.0"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "mstlo-macros"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  edition = "2024"
5
5
  description = "Procedural macros for the mstlo online Signal Temporal Logic (STL) monitoring library."
6
6
  license-file = "LICENSE"
@@ -68,15 +68,6 @@ pub trait StlOperatorTrait<T: Clone>: DynClone + Display + SignalIdentifier {
68
68
  /// The default implementation is a no-op, suitable for stateless operators like `Atomic`.
69
69
  fn reset(&mut self) {}
70
70
 
71
- /// The timestamp through which this operator's output stream is gap-free, or `None`
72
- /// if it has no gaps.
73
- ///
74
- /// Eager `And`/`Or` can emit ahead of an earlier breakpoint that arrives later.
75
- /// Consumers only close windows up to this bound.
76
- fn known_through(&self) -> Option<Duration> {
77
- None
78
- }
79
-
80
71
  /// Returns estimated total memory (stack + heap) in bytes consumed by this
81
72
  /// operator and all its children recursively.
82
73
  ///
@@ -10,7 +10,7 @@ use crate::core::{
10
10
  Reach, RobustnessSemantics, SignalIdentifier, StlOperatorAndSignalIdentifier, StlOperatorTrait,
11
11
  };
12
12
  use crate::ring_buffer::{RingBufferTrait, Step, guarded_prune};
13
- use std::collections::{HashSet, VecDeque};
13
+ use std::collections::HashSet;
14
14
  use std::fmt::{Debug, Display};
15
15
  use std::time::Duration;
16
16
 
@@ -19,16 +19,47 @@ fn joint_frontier(left: Option<Duration>, right: Option<Duration>) -> Option<Dur
19
19
  Some(left?.min(right?))
20
20
  }
21
21
 
22
- /// Caps a frontier by what the operand reports as gap-free; see
23
- /// [`StlOperatorTrait::known_through`].
22
+ /// How far eager output is settled: the joint frontier, extended for as long as the
23
+ /// leading operand holds `short_circuit_val` without interruption from there on.
24
24
  ///
25
- /// This bound can decrease, so apply it after taking the max with the newest emitted
26
- /// timestamp.
27
- fn clamp_to_known(frontier: Option<Duration>, known: Option<Duration>) -> Option<Duration> {
28
- match known {
29
- Some(bound) => Some(frontier?.min(bound)),
30
- None => frontier,
25
+ /// The lagging operand cannot change the output over that stretch.
26
+ fn eager_known_through<C, Y>(
27
+ left: (&C, Option<Duration>),
28
+ right: (&C, Option<Duration>),
29
+ short_circuit_val: Y,
30
+ ) -> Option<Duration>
31
+ where
32
+ C: RingBufferTrait<Value = Y>,
33
+ Y: PartialEq,
34
+ {
35
+ let (cache, frontier) = if left.1 > right.1 { left } else { right };
36
+ // Until the lagging operand reports at all, the stretch starts at the leading
37
+ // operand's first entry: nothing is pruned before then, and the output is undefined
38
+ // earlier.
39
+ let joint = joint_frontier(left.1, right.1).or_else(|| {
40
+ cache
41
+ .iter()
42
+ .next()
43
+ .filter(|entry| entry.value == short_circuit_val)
44
+ .map(|entry| entry.timestamp)
45
+ })?;
46
+ let held_at_joint = cache
47
+ .partition_point(|entry| entry.timestamp <= joint)
48
+ .saturating_sub(1);
49
+
50
+ let mut known = joint;
51
+ let mut covered = joint;
52
+ for entry in cache.iter().skip(held_at_joint) {
53
+ if entry.timestamp > covered
54
+ || Some(entry.timestamp) > frontier
55
+ || entry.value != short_circuit_val
56
+ {
57
+ break;
58
+ }
59
+ known = known.max(entry.timestamp);
60
+ covered = entry.held_until;
31
61
  }
62
+ Some(known)
32
63
  }
33
64
 
34
65
  /// Whether `ts` lies inside the region both operands have reported on.
@@ -38,14 +69,13 @@ fn within(joint: Option<Duration>, ts: Duration) -> bool {
38
69
 
39
70
  /// Settles the emission watermark for eager mode after a batch of output.
40
71
  ///
41
- /// Advances it to the newest output at or below the joint frontier. Timestamps
42
- /// short-circuited past that frontier are recorded separately, since the lagging operand
43
- /// can still deliver an earlier breakpoint.
72
+ /// Advances it to the newest output at or below the joint frontier. Outputs
73
+ /// short-circuited past that frontier do not move it, since the lagging operand still has
74
+ /// to be read there.
44
75
  fn settle_eager_watermark<Y>(
45
76
  output: &[Step<Y>],
46
77
  joint: Option<Duration>,
47
78
  last_eval_time: &mut Option<Duration>,
48
- answered_beyond_joint: &mut VecDeque<Duration>,
49
79
  ) {
50
80
  if let Some(eval_time) = output
51
81
  .iter()
@@ -54,18 +84,6 @@ fn settle_eager_watermark<Y>(
54
84
  {
55
85
  *last_eval_time = Some(eval_time);
56
86
  }
57
-
58
- answered_beyond_joint.extend(
59
- output
60
- .iter()
61
- .map(|step| step.timestamp)
62
- .filter(|ts| !within(joint, *ts)),
63
- );
64
-
65
- // Timestamps the watermark has caught up with no longer need tracking.
66
- if let Some(last) = *last_eval_time {
67
- answered_beyond_joint.retain(|ts| *ts > last);
68
- }
69
87
  }
70
88
 
71
89
  /// One side of a binary operator: the cache of what that operand has emitted.
@@ -73,14 +91,22 @@ struct Operand<'a, C> {
73
91
  cache: &'a C,
74
92
  /// Newest timestamp the operand has produced a value for.
75
93
  frontier: Option<Duration>,
76
- /// The operand's [`StlOperatorTrait::get_max_lookahead`].
77
- lookahead: Duration,
94
+ /// Newest timestamp the operand has produced a *final* value for. Under RoSI a
95
+ /// non-final interval may still be refined, so a held value past this mark is
96
+ /// re-masked (see [`read_operand`]).
97
+ settled: Option<Duration>,
98
+ /// The operand's [`StlOperatorTrait::reach`], used to mask RoSI bounds.
99
+ reach: Reach,
78
100
  }
79
101
 
80
102
  /// Reads the operand's value at `ts` from its cache under zero-order hold.
81
103
  ///
82
- /// `None` past the operand's frontier. Under RoSI, a `ts` within `lookahead` of the
83
- /// frontier may still be refined and reads as [`RobustnessSemantics::unknown`].
104
+ /// `None` past the operand's frontier. Under RoSI, a value held from an earlier entry
105
+ /// (`entry.timestamp < ts`) is only trusted where the operand has settled; past that
106
+ /// mark its bounds whose [`Reach`] lag has not elapsed are widened to
107
+ /// [`RobustnessSemantics::unknown`]. A fresh emission at `ts`, or one within the settled
108
+ /// region, is used as-is, so a settled bound (e.g. a positive `F` lower bound read by an
109
+ /// `or`) is never discarded.
84
110
  ///
85
111
  /// `newest` is the operand's newest entry at or before `ts`, tracked by the caller's walk.
86
112
  fn read_operand<C, Y, const IS_ROSI: bool>(
@@ -92,19 +118,12 @@ where
92
118
  C: RingBufferTrait<Value = Y>,
93
119
  Y: RobustnessSemantics + Copy,
94
120
  {
95
- if !within(operand.frontier, ts) {
96
- return None;
97
- }
98
- if IS_ROSI
99
- && operand
100
- .frontier
101
- .is_some_and(|bound| ts + operand.lookahead > bound)
102
- {
103
- return Some(Y::unknown());
121
+ let frontier = operand.frontier.filter(|bound| ts <= *bound)?;
122
+ let entry = newest.filter(|entry| entry.held_until > ts)?;
123
+ if IS_ROSI && entry.timestamp < ts && operand.settled.is_none_or(|settled| ts > settled) {
124
+ return Some(operand.reach.mask(entry.value, ts, frontier));
104
125
  }
105
- newest
106
- .filter(|entry| entry.held_until > ts)
107
- .map(|entry| entry.value)
126
+ Some(entry.value)
108
127
  }
109
128
 
110
129
  /// A unified binary processor that handles Delayed, Eager, and Refinable (RoSI) semantics correctly.
@@ -113,7 +132,8 @@ where
113
132
  /// operand under zero-order hold.
114
133
  ///
115
134
  /// A timestamp is answered once both operands are known there. Eager mode may also
116
- /// short-circuit on one operand alone past the joint frontier, in timestamp order.
135
+ /// short-circuit on one operand alone past the joint frontier, but only as far as
136
+ /// [`eager_known_through`], so output is always emitted in timestamp order.
117
137
  fn process_binary<C, Y, F, const IS_EAGER: bool, const IS_ROSI: bool>(
118
138
  left: &Operand<'_, C>,
119
139
  right: &Operand<'_, C>,
@@ -137,6 +157,16 @@ where
137
157
  let Some(horizon) = horizon else {
138
158
  return output_robustness;
139
159
  };
160
+ // Eager only short-circuits past the joint frontier over this stretch, so the lagging
161
+ // operand can never deliver a breakpoint that changes an earlier output.
162
+ let settled = match short_circuit_val {
163
+ Some(value) if IS_EAGER && !IS_ROSI => eager_known_through(
164
+ (left.cache, left.frontier),
165
+ (right.cache, right.frontier),
166
+ value,
167
+ ),
168
+ _ => None,
169
+ };
140
170
 
141
171
  // Skip breakpoints already answered. RoSI passes `None`, since it refines past outputs.
142
172
  let l_skip = start_after.map_or(0, |ts| {
@@ -182,7 +212,7 @@ where
182
212
  (Some(_), None) | (None, Some(_)) if joint.is_some_and(|frontier| ts <= frontier) => {}
183
213
  // Eager past the joint frontier: short-circuit if possible, otherwise wait.
184
214
  (Some(value), None) | (None, Some(value)) => {
185
- if short_circuit_val != Some(value) {
215
+ if short_circuit_val != Some(value) || settled.is_none_or(|bound| ts > bound) {
186
216
  break;
187
217
  }
188
218
  output_robustness.push(Step::new("output", value, ts));
@@ -204,12 +234,14 @@ pub struct And<T, C, Y, const IS_EAGER: bool, const IS_ROSI: bool> {
204
234
  left_cache: C,
205
235
  right_cache: C,
206
236
  last_eval_time: Option<Duration>,
207
- /// Timestamps eager answered ahead of the joint frontier; see
208
- /// [`settle_eager_watermark`].
209
- answered_beyond_joint: VecDeque<Duration>,
210
237
  /// Newest timestamp each operand has produced a value for.
211
238
  left_frontier: Option<Duration>,
212
239
  right_frontier: Option<Duration>,
240
+ /// Newest timestamp each operand has produced a *final* value for.
241
+ left_settled: Option<Duration>,
242
+ right_settled: Option<Duration>,
243
+ /// Eager only: see [`eager_known_through`].
244
+ known: Option<Duration>,
213
245
  left_signals_set: HashSet<&'static str>,
214
246
  right_signals_set: HashSet<&'static str>,
215
247
  max_lookahead: Duration,
@@ -237,9 +269,11 @@ impl<T, C, Y, const IS_EAGER: bool, const IS_ROSI: bool> And<T, C, Y, IS_EAGER,
237
269
  left_cache: left_cache.unwrap_or_else(|| C::new()),
238
270
  right_cache: right_cache.unwrap_or_else(|| C::new()),
239
271
  last_eval_time: None,
240
- answered_beyond_joint: VecDeque::new(),
241
272
  left_frontier: None,
242
273
  right_frontier: None,
274
+ left_settled: None,
275
+ right_settled: None,
276
+ known: None,
243
277
  left_signals_set: HashSet::new(),
244
278
  right_signals_set: HashSet::new(),
245
279
  max_lookahead,
@@ -264,19 +298,10 @@ where
264
298
  self.left.reach().join(self.right.reach())
265
299
  }
266
300
 
267
- /// Eager output is gap-free up to the joint frontier. Other modes have no gaps.
268
- fn known_through(&self) -> Option<Duration> {
269
- if !IS_EAGER || IS_ROSI {
270
- return None;
271
- }
272
- Some(joint_frontier(self.left_frontier, self.right_frontier).unwrap_or(Duration::ZERO))
273
- }
274
-
275
301
  fn total_size(&self) -> usize {
276
302
  std::mem::size_of::<Self>()
277
303
  + self.left_cache.heap_size()
278
304
  + self.right_cache.heap_size()
279
- + self.answered_beyond_joint.capacity() * std::mem::size_of::<Duration>()
280
305
  + self.left_signals_set.capacity() * (std::mem::size_of::<&str>() + 1)
281
306
  + self.right_signals_set.capacity() * (std::mem::size_of::<&str>() + 1)
282
307
  + self.left.total_size()
@@ -287,9 +312,11 @@ where
287
312
  self.left_cache.clear();
288
313
  self.right_cache.clear();
289
314
  self.last_eval_time = None;
290
- self.answered_beyond_joint.clear();
291
315
  self.left_frontier = None;
292
316
  self.right_frontier = None;
317
+ self.left_settled = None;
318
+ self.right_settled = None;
319
+ self.known = None;
293
320
  self.left.reset();
294
321
  self.right.reset();
295
322
  }
@@ -324,8 +351,10 @@ where
324
351
  if let Some(last) = left_updates.last() {
325
352
  self.left_frontier = self.left_frontier.max(Some(last.timestamp));
326
353
  }
327
- self.left_frontier = clamp_to_known(self.left_frontier, self.left.known_through());
328
354
  for update in &left_updates {
355
+ if update.value.is_final() {
356
+ self.left_settled = self.left_settled.max(Some(update.timestamp));
357
+ }
329
358
  if check_relevance(update.timestamp, self.last_eval_time)
330
359
  && !self.left_cache.update_step(update.clone())
331
360
  {
@@ -344,8 +373,10 @@ where
344
373
  if let Some(last) = right_updates.last() {
345
374
  self.right_frontier = self.right_frontier.max(Some(last.timestamp));
346
375
  }
347
- self.right_frontier = clamp_to_known(self.right_frontier, self.right.known_through());
348
376
  for update in &right_updates {
377
+ if update.value.is_final() {
378
+ self.right_settled = self.right_settled.max(Some(update.timestamp));
379
+ }
349
380
  if check_relevance(update.timestamp, self.last_eval_time)
350
381
  && !self.right_cache.update_step(update.clone())
351
382
  {
@@ -358,12 +389,14 @@ where
358
389
  &Operand {
359
390
  cache: &self.left_cache,
360
391
  frontier: self.left_frontier,
361
- lookahead: self.left.get_max_lookahead(),
392
+ settled: self.left_settled,
393
+ reach: self.left.reach(),
362
394
  },
363
395
  &Operand {
364
396
  cache: &self.right_cache,
365
397
  frontier: self.right_frontier,
366
- lookahead: self.right.get_max_lookahead(),
398
+ settled: self.right_settled,
399
+ reach: self.right.reach(),
367
400
  },
368
401
  if IS_ROSI { None } else { self.last_eval_time },
369
402
  Y::and,
@@ -375,13 +408,17 @@ where
375
408
  output.retain(|step| step.timestamp > last_time);
376
409
  }
377
410
 
378
- // Drop repeats of timestamps already short-circuited beyond the joint frontier.
379
- if IS_EAGER && !IS_ROSI && !self.answered_beyond_joint.is_empty() {
380
- output.retain(|step| !self.answered_beyond_joint.contains(&step.timestamp));
381
- }
382
-
383
411
  let lookahead = self.get_max_lookahead();
384
412
  let joint = joint_frontier(self.left_frontier, self.right_frontier);
413
+ if IS_EAGER && !IS_ROSI {
414
+ // A late breakpoint inside the settled stretch repeats the held value.
415
+ output.retain(|step| self.known.is_none_or(|known| step.timestamp > known));
416
+ self.known = eager_known_through(
417
+ (&self.left_cache, self.left_frontier),
418
+ (&self.right_cache, self.right_frontier),
419
+ Y::atomic_false(),
420
+ );
421
+ }
385
422
 
386
423
  // we protect up to the minimum of the last known timestamps minus lookahead
387
424
  // we can safely prune it if both sides have verdict and are beyond lookahead
@@ -397,12 +434,7 @@ where
397
434
  self.last_eval_time = Some(eval_time);
398
435
  }
399
436
  } else if IS_EAGER {
400
- settle_eager_watermark(
401
- &output,
402
- joint,
403
- &mut self.last_eval_time,
404
- &mut self.answered_beyond_joint,
405
- );
437
+ settle_eager_watermark(&output, joint, &mut self.last_eval_time);
406
438
  } else {
407
439
  // For delayed/bool, we track the *end* because everything before is finalized
408
440
  if let Some(eval_time) = output.last().map(|step| step.timestamp) {
@@ -440,12 +472,14 @@ pub struct Or<T, C, Y, const IS_EAGER: bool, const IS_ROSI: bool> {
440
472
  left_cache: C,
441
473
  right_cache: C,
442
474
  last_eval_time: Option<Duration>,
443
- /// Timestamps eager answered ahead of the joint frontier; see
444
- /// [`settle_eager_watermark`].
445
- answered_beyond_joint: VecDeque<Duration>,
446
475
  /// Newest timestamp each operand has produced a value for.
447
476
  left_frontier: Option<Duration>,
448
477
  right_frontier: Option<Duration>,
478
+ /// Newest timestamp each operand has produced a *final* value for.
479
+ left_settled: Option<Duration>,
480
+ right_settled: Option<Duration>,
481
+ /// Eager only: see [`eager_known_through`].
482
+ known: Option<Duration>,
449
483
  left_signals_set: HashSet<&'static str>,
450
484
  right_signals_set: HashSet<&'static str>,
451
485
  max_lookahead: Duration,
@@ -473,9 +507,11 @@ impl<T, C, Y, const IS_EAGER: bool, const IS_ROSI: bool> Or<T, C, Y, IS_EAGER, I
473
507
  left_cache: left_cache.unwrap_or_else(|| C::new()),
474
508
  right_cache: right_cache.unwrap_or_else(|| C::new()),
475
509
  last_eval_time: None,
476
- answered_beyond_joint: VecDeque::new(),
477
510
  left_frontier: None,
478
511
  right_frontier: None,
512
+ left_settled: None,
513
+ right_settled: None,
514
+ known: None,
479
515
  left_signals_set: HashSet::new(),
480
516
  right_signals_set: HashSet::new(),
481
517
  max_lookahead,
@@ -500,19 +536,10 @@ where
500
536
  self.left.reach().join(self.right.reach())
501
537
  }
502
538
 
503
- /// Eager output is gap-free up to the joint frontier. Other modes have no gaps.
504
- fn known_through(&self) -> Option<Duration> {
505
- if !IS_EAGER || IS_ROSI {
506
- return None;
507
- }
508
- Some(joint_frontier(self.left_frontier, self.right_frontier).unwrap_or(Duration::ZERO))
509
- }
510
-
511
539
  fn total_size(&self) -> usize {
512
540
  std::mem::size_of::<Self>()
513
541
  + self.left_cache.heap_size()
514
542
  + self.right_cache.heap_size()
515
- + self.answered_beyond_joint.capacity() * std::mem::size_of::<Duration>()
516
543
  + self.left_signals_set.capacity() * (std::mem::size_of::<&str>() + 1)
517
544
  + self.right_signals_set.capacity() * (std::mem::size_of::<&str>() + 1)
518
545
  + self.left.total_size()
@@ -523,9 +550,11 @@ where
523
550
  self.left_cache.clear();
524
551
  self.right_cache.clear();
525
552
  self.last_eval_time = None;
526
- self.answered_beyond_joint.clear();
527
553
  self.left_frontier = None;
528
554
  self.right_frontier = None;
555
+ self.left_settled = None;
556
+ self.right_settled = None;
557
+ self.known = None;
529
558
  self.left.reset();
530
559
  self.right.reset();
531
560
  }
@@ -560,8 +589,10 @@ where
560
589
  if let Some(last) = left_updates.last() {
561
590
  self.left_frontier = self.left_frontier.max(Some(last.timestamp));
562
591
  }
563
- self.left_frontier = clamp_to_known(self.left_frontier, self.left.known_through());
564
592
  for update in &left_updates {
593
+ if update.value.is_final() {
594
+ self.left_settled = self.left_settled.max(Some(update.timestamp));
595
+ }
565
596
  if check_relevance(update.timestamp, self.last_eval_time)
566
597
  && !self.left_cache.update_step(update.clone())
567
598
  {
@@ -580,8 +611,10 @@ where
580
611
  if let Some(last) = right_updates.last() {
581
612
  self.right_frontier = self.right_frontier.max(Some(last.timestamp));
582
613
  }
583
- self.right_frontier = clamp_to_known(self.right_frontier, self.right.known_through());
584
614
  for update in &right_updates {
615
+ if update.value.is_final() {
616
+ self.right_settled = self.right_settled.max(Some(update.timestamp));
617
+ }
585
618
  if check_relevance(update.timestamp, self.last_eval_time)
586
619
  && !self.right_cache.update_step(update.clone())
587
620
  {
@@ -594,12 +627,14 @@ where
594
627
  &Operand {
595
628
  cache: &self.left_cache,
596
629
  frontier: self.left_frontier,
597
- lookahead: self.left.get_max_lookahead(),
630
+ settled: self.left_settled,
631
+ reach: self.left.reach(),
598
632
  },
599
633
  &Operand {
600
634
  cache: &self.right_cache,
601
635
  frontier: self.right_frontier,
602
- lookahead: self.right.get_max_lookahead(),
636
+ settled: self.right_settled,
637
+ reach: self.right.reach(),
603
638
  },
604
639
  if IS_ROSI { None } else { self.last_eval_time },
605
640
  Y::or,
@@ -611,13 +646,17 @@ where
611
646
  output.retain(|step| step.timestamp > last_time);
612
647
  }
613
648
 
614
- // Drop repeats of timestamps already short-circuited beyond the joint frontier.
615
- if IS_EAGER && !IS_ROSI && !self.answered_beyond_joint.is_empty() {
616
- output.retain(|step| !self.answered_beyond_joint.contains(&step.timestamp));
617
- }
618
-
619
649
  let lookahead = self.get_max_lookahead();
620
650
  let joint = joint_frontier(self.left_frontier, self.right_frontier);
651
+ if IS_EAGER && !IS_ROSI {
652
+ // A late breakpoint inside the settled stretch repeats the held value.
653
+ output.retain(|step| self.known.is_none_or(|known| step.timestamp > known));
654
+ self.known = eager_known_through(
655
+ (&self.left_cache, self.left_frontier),
656
+ (&self.right_cache, self.right_frontier),
657
+ Y::atomic_true(),
658
+ );
659
+ }
621
660
 
622
661
  // we protect up to the minimum of the last known timestamps minus lookahead
623
662
  // we can safely prune it if both sides have verdict and are beyond lookahead
@@ -633,12 +672,7 @@ where
633
672
  self.last_eval_time = Some(eval_time);
634
673
  }
635
674
  } else if IS_EAGER {
636
- settle_eager_watermark(
637
- &output,
638
- joint,
639
- &mut self.last_eval_time,
640
- &mut self.answered_beyond_joint,
641
- );
675
+ settle_eager_watermark(&output, joint, &mut self.last_eval_time);
642
676
  } else {
643
677
  // For delayed/bool, we track the *end* because everything before is finalized
644
678
  if let Some(eval_time) = output.last().map(|step| step.timestamp) {
@@ -58,11 +58,6 @@ where
58
58
  self.operand.reset();
59
59
  }
60
60
 
61
- /// Negation is pointwise, so this stream has exactly the operand's holes.
62
- fn known_through(&self) -> Option<Duration> {
63
- self.operand.known_through()
64
- }
65
-
66
61
  /// Updates the child operator and negates each produced value.
67
62
  fn update(&mut self, step: &Step<T>) -> Vec<Step<Self::Output>> {
68
63
  let operand_updates = self.operand.update(step);