Skip to main content

bhtune_driver/
replay.rs

1//! `ReplayDriver`: feeds a previously captured golden-master trace back through the
2//! [`Driver`] trait, tick by tick, instead of a live OPC DA connection or the in-process
3//! FOPDT simulator.
4//!
5//! `core-replay-harness` (`crates/bhtune-core/tests/golden_replay.rs`) already proves the
6//! *pure* `MrftEngine` reproduces the legacy C# application's behavior exactly, by feeding a
7//! fixture's recorded ticks directly into `engine.step(Tick { time, pv })`. That test cannot
8//! exercise anything in this crate at all -- `bhtune-core` cannot depend on `bhtune-driver`,
9//! which depends on it -- so it says nothing about whether the *real* async `Driver`
10//! abstraction a live run actually goes through (tag-based indirection, `TagValue`/
11//! `TagWrite` conversions, `Quality`, `Send`-across-`.await` dispatch behind `Box<dyn
12//! Driver>`/`Arc<dyn Driver>`) introduces its own bugs on top of a provably-correct engine.
13//! `ReplayDriver` closes that gap: it serves the exact same recorded trace through the
14//! genuine trait boundary, so a validation test can drive a real `MrftEngine` through it and
15//! confirm the same golden trace still reaches the same answer -- see this module's own
16//! `mrft_engine_replays_the_golden_trace_through_the_real_driver_trait` test.
17
18use std::sync::Mutex;
19
20use async_trait::async_trait;
21use chrono::{DateTime, Utc};
22use serde::Deserialize;
23
24use crate::{
25    driver::Driver,
26    error::{DriverError, DriverResult},
27    types::{
28        BrowsePage, BrowsePageRequest, DriverCapabilities, Quality, SearchEvent, SearchRequest,
29        TagId, TagValue, TagWrite, WriteOutcome,
30    },
31};
32
33/// One recorded `(time, PV)` sample from a captured trace -- the two fields
34/// [`ReplayDriver`] actually needs to serve a PV read.
35///
36/// Deliberately not `bhtune-core`'s `Tick`: this crate stays free of a `bhtune-core`
37/// dependency in production code (matching `driver-trait`/`driver-opcda`/
38/// `driver-simulator`'s "reading/writing named string tags has no domain meaning by
39/// itself" rule -- see `driver-trait`'s design notes in `AGENTS.md`). Also deliberately not
40/// the full golden-fixture JSON schema `crates/bhtune-core/tests/golden_replay.rs` owns
41/// (`config`, `direction`, `initial`, `pv_range`, `template_name`, each tick's `expected`
42/// block, `expected_final`) -- none of that is needed to *serve* a replay, only to
43/// *validate* one, which stays the calling test's job.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct ReplaySample {
46    pub time: DateTime<Utc>,
47    pub pv: f32,
48}
49
50/// A single MV write [`ReplayDriver`] observed, in the order [`Driver::write`] was called
51/// -- what a validation test inspects afterward (via [`ReplayDriver::writes`]) to see
52/// exactly what an engine driven through the real `Driver` trait chose to write, without
53/// needing its own separate bookkeeping alongside the driver's.
54#[derive(Debug, Clone, PartialEq)]
55pub struct RecordedWrite {
56    pub tag: TagId,
57    pub value: f32,
58}
59
60/// The subset of a golden-master fixture's JSON shape [`ReplayDriver::from_fixture_json`]
61/// parses -- every other top-level or per-tick field is silently ignored by `serde`'s
62/// default "unknown fields are fine" behavior, since none of it is needed to serve PV
63/// samples. See [`ReplaySample`]'s doc comment for why this is a deliberate subset rather
64/// than reusing `bhtune-core`'s test-only `Fixture` type.
65#[derive(Debug, Deserialize)]
66struct FixtureFile {
67    ticks: Vec<FixtureTick>,
68}
69
70#[derive(Debug, Deserialize)]
71struct FixtureTick {
72    time: DateTime<Utc>,
73    pv: f32,
74}
75
76/// The error [`Driver::read`] wraps in [`DriverError::Operation`] when the configured PV
77/// tag is read after every recorded sample has already been consumed.
78///
79/// A correctly captured trace paired with a correctly behaving engine should never reach
80/// this: `MrftEngine::step` is a documented no-op once it has returned `Action::Complete`
81/// once (see `crates/bhtune-core/tests/golden_replay.rs`'s own note on this), so a caller
82/// that stops polling as soon as completion is observed -- exactly the shape that test and
83/// this module's own end-to-end test both use -- never triggers it. Seeing this in practice
84/// means either the trace's tick count doesn't actually cover a real MRFT completion, or the
85/// engine driving this driver has a genuine regression that fails to complete in time.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct ReplayTraceExhausted {
88    /// How many samples this driver was constructed with.
89    pub recorded: usize,
90    /// The 1-based read attempt number that failed (`recorded + 1`, `recorded + 2`, ...).
91    pub attempted: usize,
92}
93
94impl std::fmt::Display for ReplayTraceExhausted {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(
97            f,
98            "replay trace exhausted: only {} sample(s) recorded, but PV read attempt #{} was \
99             made -- the driving engine never reported completion within the recorded trace",
100            self.recorded, self.attempted
101        )
102    }
103}
104
105impl std::error::Error for ReplayTraceExhausted {}
106
107#[derive(Debug)]
108struct ReplayState {
109    next_index: usize,
110    last_mv: f32,
111    writes: Vec<RecordedWrite>,
112}
113
114/// Serves a captured `(time, PV)` trace through the real [`Driver`] trait, for validating
115/// that a live `MrftEngine` run reproduces a golden-master trace's result when driven
116/// through the actual async abstraction -- not just when fed the trace directly, as
117/// `core-replay-harness` already does at the pure-engine level.
118///
119/// Reading the configured PV tag returns the next unconsumed sample's PV value, with its
120/// *real* recorded time in `TagValue.timestamp`. This is the one [`Driver`] implementation
121/// in this crate where that field is genuinely meaningful rather than a diagnostic-only
122/// extra: [`crate::types::TagValue`]'s own doc comment is clear that a live driver's
123/// timestamp must never become "the tick time the tuning engine itself runs on, which comes
124/// from the caller's own polling clock instead" -- true and load-bearing for
125/// `OpcDaDriver`/`SimulatorDriver`, whose reported (or absent) timestamps cannot be
126/// trusted to reconstruct a control loop's real tick cadence. `ReplayDriver` is a
127/// deliberate, narrow exception to that rule, not a violation of it: it is not a live
128/// driver at all, its entire purpose is exact historical replay, and the recorded time
129/// *is* the tick time a validation test needs -- reading it straight back out of the trait
130/// boundary is simpler and less redundant than threading the same value through some
131/// separate side channel the test would otherwise have to keep in lockstep with this
132/// driver's own internal cursor.
133///
134/// Reading the configured MV tag returns the last written value (or the seeded initial MV
135/// before any write), matching [`crate::SimulatorDriver`]'s convention exactly. Running a
136/// PV read past the last recorded sample is [`DriverError::Operation`] (wrapping
137/// [`ReplayTraceExhausted`]) rather than panicking or silently repeating/holding the last
138/// value, since either of those would let a real regression (the engine failing to
139/// complete) masquerade as a passing test.
140///
141/// Uses `std::sync::Mutex`, matching [`crate::SimulatorDriver`]: every operation here is
142/// synchronous index/vec bookkeeping with no `.await` point in the critical section.
143#[derive(Debug)]
144pub struct ReplayDriver {
145    pv_tag: TagId,
146    mv_tag: TagId,
147    samples: Vec<ReplaySample>,
148    state: Mutex<ReplayState>,
149}
150
151impl ReplayDriver {
152    /// Builds a replay driver from an already-parsed sample sequence. `initial_mv` is what
153    /// the MV tag reads as before the first write -- mirroring
154    /// [`crate::SimulatorDriver::new`]'s `initial_mv` parameter, and matching the fact that
155    /// a real trace's MV convention (see `crates/bhtune-core/tests/golden_replay.rs`'s
156    /// `FixtureInitial::mv_ini`) is likewise supplied out of band from the tick sequence
157    /// itself.
158    pub fn new(
159        pv_tag: impl Into<TagId>,
160        mv_tag: impl Into<TagId>,
161        samples: Vec<ReplaySample>,
162        initial_mv: f32,
163    ) -> ReplayDriver {
164        ReplayDriver {
165            pv_tag: pv_tag.into(),
166            mv_tag: mv_tag.into(),
167            samples,
168            state: Mutex::new(ReplayState {
169                next_index: 0,
170                last_mv: initial_mv,
171                writes: Vec::new(),
172            }),
173        }
174    }
175
176    /// Parses a golden-master fixture JSON document's `ticks[].time`/`ticks[].pv` fields
177    /// (see [`FixtureFile`]) into the sample sequence [`ReplayDriver::new`] expects, so a
178    /// validation test can point this driver directly at the same fixture file
179    /// `core-replay-harness` already validates against (`tests/golden/fixtures/*.json`)
180    /// rather than hand-transcribing the tick sequence a second time. A parse failure is
181    /// [`DriverError::Operation`], matching this crate's error-model doc comment's own
182    /// forward-looking note that golden-trace parse errors belong there.
183    pub fn from_fixture_json(
184        pv_tag: impl Into<TagId>,
185        mv_tag: impl Into<TagId>,
186        json: &str,
187        initial_mv: f32,
188    ) -> DriverResult<ReplayDriver> {
189        let file: FixtureFile =
190            serde_json::from_str(json).map_err(|e| DriverError::Operation(Box::new(e)))?;
191        let samples = file
192            .ticks
193            .into_iter()
194            .map(|t| ReplaySample {
195                time: t.time,
196                pv: t.pv,
197            })
198            .collect();
199        Ok(ReplayDriver::new(pv_tag, mv_tag, samples, initial_mv))
200    }
201
202    /// Every MV write observed so far, in call order -- for a validation test to compare
203    /// against a golden fixture's own expected per-tick MV sequence.
204    pub fn writes(&self) -> Vec<RecordedWrite> {
205        self.state.lock().unwrap().writes.clone()
206    }
207
208    /// How many configured samples have not yet been consumed by a PV read.
209    pub fn remaining(&self) -> usize {
210        let state = self.state.lock().unwrap();
211        self.samples.len() - state.next_index
212    }
213}
214
215#[async_trait]
216impl Driver for ReplayDriver {
217    async fn read(&self, tags: &[TagId]) -> DriverResult<Vec<TagValue>> {
218        let mut state = self.state.lock().unwrap();
219        tags.iter()
220            .map(|tag| {
221                if *tag == self.pv_tag {
222                    let index = state.next_index;
223                    let sample = self.samples.get(index).ok_or_else(|| {
224                        DriverError::Operation(Box::new(ReplayTraceExhausted {
225                            recorded: self.samples.len(),
226                            attempted: index + 1,
227                        }))
228                    })?;
229                    state.next_index += 1;
230                    Ok(TagValue {
231                        tag: tag.clone(),
232                        value: sample.pv.to_string(),
233                        quality: Quality::Good,
234                        timestamp: Some(sample.time),
235                    })
236                } else if *tag == self.mv_tag {
237                    Ok(TagValue {
238                        tag: tag.clone(),
239                        value: state.last_mv.to_string(),
240                        quality: Quality::Good,
241                        timestamp: None,
242                    })
243                } else {
244                    Err(DriverError::InvalidTagValue {
245                        tag: tag.clone(),
246                        message: "ReplayDriver only knows its configured PV/MV tags".to_string(),
247                    })
248                }
249            })
250            .collect()
251    }
252
253    async fn write(&self, tag: &TagId, value: TagWrite) -> DriverResult<WriteOutcome> {
254        if *tag != self.mv_tag {
255            return Err(DriverError::InvalidTagValue {
256                tag: tag.clone(),
257                message: "ReplayDriver only accepts writes to its configured MV tag".to_string(),
258            });
259        }
260        let mv = match value {
261            TagWrite::Float(f) => f,
262            TagWrite::Raw(s) => match s.parse::<f32>() {
263                Ok(f) => f,
264                Err(_) => {
265                    return Ok(WriteOutcome::failure(format!(
266                        "'{s}' is not a valid numeric MV value"
267                    )));
268                }
269            },
270        };
271        let mut state = self.state.lock().unwrap();
272        state.last_mv = mv;
273        state.writes.push(RecordedWrite {
274            tag: tag.clone(),
275            value: mv,
276        });
277        Ok(WriteOutcome::success())
278    }
279
280    async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
281        Err(DriverError::Unsupported {
282            operation: "capabilities",
283        })
284    }
285
286    async fn browse(&self, _request: BrowsePageRequest) -> DriverResult<BrowsePage> {
287        Err(DriverError::Unsupported {
288            operation: "browse",
289        })
290    }
291
292    async fn close_browse_session(&self, _session_id: &str) -> DriverResult<()> {
293        Err(DriverError::Unsupported {
294            operation: "browse-session close",
295        })
296    }
297
298    async fn search(&self, _request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
299        Err(DriverError::Unsupported {
300            operation: "search",
301        })
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use chrono::TimeZone;
309
310    fn t(secs: i64) -> DateTime<Utc> {
311        Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::seconds(secs)
312    }
313
314    fn samples() -> Vec<ReplaySample> {
315        vec![
316            ReplaySample {
317                time: t(0),
318                pv: 10.0,
319            },
320            ReplaySample {
321                time: t(1),
322                pv: 11.0,
323            },
324            ReplaySample {
325                time: t(2),
326                pv: 12.0,
327            },
328        ]
329    }
330
331    fn expect_trace_exhaustion(error: DriverError) -> Box<ReplayTraceExhausted> {
332        match error {
333            DriverError::Operation(source) => source.downcast::<ReplayTraceExhausted>().unwrap(),
334            other => panic!("expected DriverError::Operation, got {other:?}"),
335        }
336    }
337
338    #[tokio::test]
339    async fn unsupported_namespace_operations_are_reported() {
340        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
341        assert!(matches!(
342            driver.capabilities().await,
343            Err(DriverError::Unsupported {
344                operation: "capabilities"
345            })
346        ));
347        assert!(matches!(
348            driver.browse(BrowsePageRequest::root(1)).await,
349            Err(DriverError::Unsupported {
350                operation: "browse"
351            })
352        ));
353        assert!(matches!(
354            driver.close_browse_session("s").await,
355            Err(DriverError::Unsupported {
356                operation: "browse-session close"
357            })
358        ));
359        assert!(matches!(
360            driver
361                .search(SearchRequest {
362                    query: "PV".into(),
363                    match_mode: crate::types::SearchMatchMode::Exact,
364                    session_id: None,
365                    scope_node_key: None,
366                    max_results: 1,
367                    include_branches: false,
368                    refresh: false,
369                })
370                .await,
371            Err(DriverError::Unsupported {
372                operation: "search"
373            })
374        ));
375    }
376
377    // --- construction / basic read-back ---------------------------------------------------
378
379    #[tokio::test]
380    async fn reads_pv_samples_in_order_with_their_recorded_timestamps() {
381        let driver = ReplayDriver::new("PV", "MV", samples(), 50.0);
382
383        for (i, expected) in samples().iter().enumerate() {
384            let read = driver.read(&["PV".to_string()]).await.unwrap();
385            assert_eq!(read.len(), 1, "tick {i}");
386            assert_eq!(read[0].tag, "PV");
387            assert_eq!(read[0].value, expected.pv.to_string(), "tick {i}");
388            assert_eq!(read[0].quality, Quality::Good, "tick {i}");
389            assert_eq!(read[0].timestamp, Some(expected.time), "tick {i}");
390        }
391    }
392
393    #[tokio::test]
394    async fn mv_read_before_any_write_returns_the_seeded_initial_value() {
395        let driver = ReplayDriver::new("PV", "MV", samples(), 42.5);
396        let read = driver.read(&["MV".to_string()]).await.unwrap();
397        assert_eq!(read[0].value, "42.5");
398        assert_eq!(read[0].timestamp, None, "MV reads have no recorded time");
399    }
400
401    #[tokio::test]
402    async fn mv_read_reflects_the_most_recent_write() {
403        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
404        driver
405            .write(&"MV".to_string(), TagWrite::Float(37.0))
406            .await
407            .unwrap();
408        let read = driver.read(&["MV".to_string()]).await.unwrap();
409        assert_eq!(read[0].value, "37");
410    }
411
412    #[tokio::test]
413    async fn reading_pv_does_not_advance_a_subsequent_mv_read_and_vice_versa() {
414        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
415        driver.read(&["MV".to_string()]).await.unwrap();
416        driver.read(&["MV".to_string()]).await.unwrap();
417        assert_eq!(
418            driver.remaining(),
419            3,
420            "MV reads must not consume PV samples"
421        );
422        driver.read(&["PV".to_string()]).await.unwrap();
423        assert_eq!(driver.remaining(), 2);
424    }
425
426    #[tokio::test]
427    async fn reading_multiple_tags_in_one_call_resolves_each_independently() {
428        let driver = ReplayDriver::new("PV", "MV", samples(), 5.0);
429        let read = driver
430            .read(&["PV".to_string(), "MV".to_string()])
431            .await
432            .unwrap();
433        assert_eq!(read[0].tag, "PV");
434        assert_eq!(read[0].value, "10");
435        assert_eq!(read[1].tag, "MV");
436        assert_eq!(read[1].value, "5");
437        assert_eq!(
438            driver.remaining(),
439            2,
440            "the one PV tag in the batch consumed one sample"
441        );
442    }
443
444    // --- writes / recording ----------------------------------------------------------------
445
446    #[tokio::test]
447    async fn writes_are_recorded_in_call_order() {
448        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
449        driver
450            .write(&"MV".to_string(), TagWrite::Float(1.0))
451            .await
452            .unwrap();
453        driver
454            .write(&"MV".to_string(), TagWrite::Float(2.0))
455            .await
456            .unwrap();
457        driver
458            .write(&"MV".to_string(), TagWrite::Raw("3".to_string()))
459            .await
460            .unwrap();
461        let writes = driver.writes();
462        assert_eq!(
463            writes,
464            vec![
465                RecordedWrite {
466                    tag: "MV".to_string(),
467                    value: 1.0
468                },
469                RecordedWrite {
470                    tag: "MV".to_string(),
471                    value: 2.0
472                },
473                RecordedWrite {
474                    tag: "MV".to_string(),
475                    value: 3.0
476                },
477            ]
478        );
479    }
480
481    #[tokio::test]
482    async fn raw_write_with_unparseable_value_is_a_rejected_outcome_not_an_error() {
483        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
484        let outcome = driver
485            .write(&"MV".to_string(), TagWrite::Raw("not-a-number".to_string()))
486            .await
487            .unwrap();
488        assert!(!outcome.success);
489        assert!(outcome.error_message.unwrap().contains("not-a-number"));
490        assert!(
491            driver.writes().is_empty(),
492            "a rejected write must not be recorded"
493        );
494    }
495
496    // --- error paths -------------------------------------------------------------------------
497
498    #[tokio::test]
499    async fn reading_an_unknown_tag_is_invalid_tag_value() {
500        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
501        let err = driver
502            .read(&["SomeOtherTag".to_string()])
503            .await
504            .unwrap_err();
505        assert!(matches!(
506            err,
507            DriverError::InvalidTagValue { tag, .. } if tag == "SomeOtherTag"
508        ));
509    }
510
511    #[tokio::test]
512    async fn writing_an_unknown_tag_is_invalid_tag_value() {
513        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
514        let err = driver
515            .write(&"SomeOtherTag".to_string(), TagWrite::Float(1.0))
516            .await
517            .unwrap_err();
518        assert!(matches!(err, DriverError::InvalidTagValue { .. }));
519    }
520
521    #[tokio::test]
522    async fn browse_is_unsupported() {
523        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
524        let err = driver
525            .browse(BrowsePageRequest::root(20))
526            .await
527            .unwrap_err();
528        assert!(matches!(
529            err,
530            DriverError::Unsupported {
531                operation: "browse"
532            }
533        ));
534    }
535
536    #[tokio::test]
537    async fn reading_pv_past_the_last_sample_is_operation_error_not_a_panic() {
538        let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
539        for _ in 0..3 {
540            driver.read(&["PV".to_string()]).await.unwrap();
541        }
542        assert_eq!(driver.remaining(), 0);
543        let err = driver.read(&["PV".to_string()]).await.unwrap_err();
544        let exhausted = expect_trace_exhaustion(err);
545        assert_eq!(exhausted.recorded, 3);
546        assert_eq!(exhausted.attempted, 4);
547        assert!(exhausted.to_string().contains("exhausted"));
548    }
549
550    #[tokio::test]
551    async fn an_empty_trace_reports_exhaustion_on_the_very_first_read() {
552        let driver = ReplayDriver::new("PV", "MV", Vec::new(), 0.0);
553        let err = driver.read(&["PV".to_string()]).await.unwrap_err();
554        let exhausted = expect_trace_exhaustion(err);
555        assert_eq!(exhausted.recorded, 0);
556        assert_eq!(exhausted.attempted, 1);
557    }
558
559    #[test]
560    fn trace_exhaustion_assertion_fails_clearly_for_a_non_operation_error() {
561        let panic = std::panic::catch_unwind(|| {
562            expect_trace_exhaustion(DriverError::Unsupported {
563                operation: "browse",
564            })
565        })
566        .unwrap_err();
567        assert!(
568            panic
569                .downcast_ref::<String>()
570                .is_some_and(|message| message.contains("DriverError::Operation"))
571        );
572    }
573
574    // --- from_fixture_json -------------------------------------------------------------------
575
576    #[test]
577    fn from_fixture_json_parses_ticks_and_ignores_every_other_field() {
578        let json = r#"{
579            "name": "example",
580            "description": "irrelevant prose",
581            "source": { "static_log": "x", "dynamic_log": "y", "captured": "2026-01-01" },
582            "config": { "process_type": "flow", "controller_type": "pi" },
583            "direction": "reverse",
584            "initial": { "pv_ini": 1.0 },
585            "pv_range": { "high": 100.0, "low": 0.0 },
586            "template_name": "Yokogawa CentumVP",
587            "ticks": [
588                { "time": "2024-01-01T00:00:00Z", "pv": 40.0, "expected": { "hysteresis": 0.1 } },
589                { "time": "2024-01-01T00:00:01Z", "pv": 41.5, "expected": { "hysteresis": 0.2 } }
590            ]
591        }"#;
592
593        let driver = ReplayDriver::from_fixture_json("PV", "MV", json, 40.0).unwrap();
594        assert_eq!(driver.remaining(), 2);
595    }
596
597    #[test]
598    fn from_fixture_json_rejects_malformed_json_as_operation_error() {
599        let err = ReplayDriver::from_fixture_json("PV", "MV", "not json", 0.0).unwrap_err();
600        assert!(matches!(err, DriverError::Operation(_)));
601    }
602
603    #[test]
604    fn from_fixture_json_rejects_a_document_with_no_ticks_field() {
605        let err = ReplayDriver::from_fixture_json("PV", "MV", "{}", 0.0).unwrap_err();
606        assert!(matches!(err, DriverError::Operation(_)));
607    }
608
609    #[test]
610    fn trace_exhaustion_error_reports_the_recorded_and_attempted_counts() {
611        let error = ReplayTraceExhausted {
612            recorded: 3,
613            attempted: 4,
614        };
615        assert!(error.to_string().contains("3 sample(s)"));
616        assert!(error.to_string().contains("#4"));
617        let _: Box<dyn std::error::Error> = Box::new(error);
618    }
619
620    // --- object safety -----------------------------------------------------------------------
621
622    #[tokio::test]
623    async fn is_usable_as_a_boxed_dyn_driver() {
624        let driver: Box<dyn Driver> = Box::new(ReplayDriver::new("PV", "MV", samples(), 0.0));
625        let read = driver.read(&["PV".to_string()]).await.unwrap();
626        assert_eq!(read[0].value, "10");
627    }
628
629    /// End-to-end: a real `MrftEngine` (from `bhtune-core`) drives `ReplayDriver` through
630    /// the actual `Driver` trait, fed from the *same* captured golden-master fixture
631    /// `core-replay-harness` (`crates/bhtune-core/tests/golden_replay.rs`) already validates
632    /// at the pure-engine level, and reaches the same final tuning result. This is
633    /// deliberately not a re-run of that test's exhaustive per-tick assertions (hysteresis,
634    /// `mv_sign_next_step`, cycle counters, ...) at every tick -- that would just duplicate
635    /// already-proven engine correctness. What this test adds is proof that the *real* async
636    /// `Driver` abstraction this trace is now served through -- tag lookup, `TagValue`/
637    /// `TagWrite` conversions, the `timestamp` field carrying the tick time -- introduces no
638    /// bugs of its own on top of an already-correct engine.
639    #[tokio::test]
640    async fn mrft_engine_replays_the_golden_trace_through_the_real_driver_trait() {
641        use std::{fs, path::Path};
642
643        use bhtune_core::{
644            Action, ControllerDirection, ControllerType, InitialReadings, LoopConfig, MrftCompat,
645            MrftEngine, ProcessType, PvRange, ResponseLevel, Tick, TuningMathCompat,
646            built_in_templates, calculate_all, lookup,
647        };
648
649        let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
650            .join("../../tests/golden/fixtures/flow_pi_direct.json");
651        let json = fs::read_to_string(&fixture_path)
652            .unwrap_or_else(|e| panic!("failed to read {}: {e}", fixture_path.display()));
653
654        let pv_tag = "Loop.PV".to_string();
655        let mv_tag = "Loop.MV".to_string();
656        // This fixture's own `initial.mv_ini` (see `crates/bhtune-core/tests/
657        // golden_replay.rs`'s `FixtureInitial`) -- kept as a literal here since this test
658        // deliberately only parses `ticks[].time`/`ticks[].pv` via `from_fixture_json`, not
659        // the fixture's other fields.
660        let initial_mv = 40.0;
661        let driver =
662            ReplayDriver::from_fixture_json(pv_tag.clone(), mv_tag.clone(), &json, initial_mv)
663                .expect("flow_pi_direct.json should parse");
664        let total_samples = driver.remaining();
665
666        // This fixture's own config/direction/initial-readings/pv-range, matching
667        // `golden_replay.rs`'s hardcoded transcription of the same fixture exactly (that
668        // test asserts the fixture's `config`/`direction` enums decode to these values, so
669        // duplicating the literals here rather than re-parsing them is a deliberate,
670        // already-covered redundancy, not a risk of silent drift).
671        let config = LoopConfig {
672            process_type: ProcessType::Flow,
673            controller_type: ControllerType::Pi,
674            relay_amp_percent: 2.0,
675            num_cycles_skip: 1,
676            num_cycles_count: 2,
677            noise_protection_secs: 3,
678            mrft_delay_secs: 0,
679        };
680        config.validate().expect("fixture config must be valid");
681        let direction = ControllerDirection::Reverse;
682        let initial = InitialReadings {
683            pv_ini: 40.00012,
684            mv_ini: initial_mv,
685            mv_range_low: 0.0,
686            mv_range_high: 100.0,
687        };
688        let pv_range = PvRange {
689            high: 100.0,
690            low: 0.0,
691        };
692        let beta = lookup(
693            config.process_type,
694            config.controller_type,
695            ResponseLevel::Aggressive,
696        )
697        .beta;
698        let template = built_in_templates()
699            .into_iter()
700            .find(|t| t.name == "Yokogawa CentumVP")
701            .expect("built-in template must exist");
702
703        // The first read's timestamp is this trace's own start time -- read here (rather
704        // than hardcoded) purely to seed `MrftEngine::new`, which needs a start time before
705        // the first `step` call.
706        let first = driver.read(std::slice::from_ref(&pv_tag)).await.unwrap();
707        let start_time = first[0]
708            .timestamp
709            .expect("ReplayDriver always sets a timestamp on PV reads");
710        let mut pending_tick = Some(Tick {
711            time: start_time,
712            pv: first[0].value.parse().unwrap(),
713        });
714
715        let mut engine = MrftEngine::new(
716            config,
717            direction,
718            beta,
719            initial,
720            start_time,
721            MrftCompat::default(),
722        );
723
724        let mut completion = None;
725        for _ in 0..total_samples {
726            let tick = match pending_tick.take() {
727                Some(tick) => tick,
728                None => {
729                    let read = driver.read(std::slice::from_ref(&pv_tag)).await.unwrap();
730                    let time = read[0]
731                        .timestamp
732                        .expect("ReplayDriver always sets a timestamp on PV reads");
733                    Tick {
734                        time,
735                        pv: read[0].value.parse().unwrap(),
736                    }
737                }
738            };
739
740            for action in engine.step(tick) {
741                match action {
742                    Action::WriteMv(mv) => {
743                        driver.write(&mv_tag, TagWrite::Float(mv)).await.unwrap();
744                    }
745                    Action::Complete {
746                        peaks,
747                        troughs,
748                        switch_times,
749                        mv_sign_init,
750                    } => {
751                        completion = Some((peaks, troughs, switch_times, mv_sign_init));
752                    }
753                }
754            }
755            if completion.is_some() {
756                break;
757            }
758        }
759
760        let (peaks, troughs, switch_times, mv_sign_init) =
761            completion.expect("engine should complete within the recorded trace");
762
763        let results = calculate_all(
764            &peaks,
765            &troughs,
766            &switch_times,
767            mv_sign_init,
768            direction,
769            config,
770            pv_range,
771            &template,
772            TuningMathCompat::default(),
773        );
774
775        // The aggressive-response proportional band, in the fixture's own DCS units --
776        // matching the same "PB=157.7" figure already confirmed control-theory-consistent
777        // and recorded against this exact capture in AGENTS.md's `capture-traces` notes.
778        // Same tolerance shape `golden_replay.rs` uses for its own final numbers -- see that
779        // test for the full rationale (float rounding, and the period-truncation-bug-driven
780        // `ti_minutes`/`integral` slack in particular, which is why this test doesn't also
781        // re-check those two fields as tightly).
782        let expected_aggressive_pb = 157.7088_f32;
783        let (_tuning, pid) = results
784            .iter()
785            .find(|(r, _)| r.response_level == ResponseLevel::Aggressive)
786            .expect("aggressive result must be present");
787        let tolerance = 1e-3 + expected_aggressive_pb.abs() * 1e-2;
788        assert!(
789            (pid.proportional - expected_aggressive_pb).abs() <= tolerance,
790            "aggressive proportional band: expected ~{expected_aggressive_pb}, got {} \
791             (tolerance {tolerance})",
792            pid.proportional
793        );
794
795        assert!(
796            !driver.writes().is_empty(),
797            "the engine should have written at least one relay step through the real \
798             Driver trait"
799        );
800        // The engine stops driving reads the instant completion is observed (matching
801        // `core-replay-harness`'s own "any remaining fixture ticks are exactly this harmless
802        // trailing data and are not replayed" behavior -- see that test's comment), so this
803        // trace's trailing padding ticks are expected to remain unconsumed; the meaningful
804        // assertion is that real consumption happened at all, not that every recorded tick
805        // was read.
806        assert!(
807            driver.remaining() < total_samples,
808            "expected at least one sample to be consumed before completion"
809        );
810    }
811}