@@ -56,9 +56,10 @@ use spar_network::extract::{
5656 extract_network_graph, read_forwarding_latency_ps, read_output_rate_bps, read_queue_depth,
5757} ;
5858use spar_network:: tsn:: {
59- CbsReservation , ClassOfService , GateSchedule , cbs_residual_service,
59+ CbsReservation , ClassOfService , GateSchedule , cbs_residual_service, frame_quantization_ps ,
6060 get_bandwidth_reservation_bps, get_class_of_service, get_frame_preemption, get_gate_schedule,
61- get_max_frame_size_bytes, is_express_stream, preemption_blocking_term_ps, tas_residual_service,
61+ get_max_frame_size_bytes, get_sync_error_ps, is_express_stream, preemption_blocking_term_ps,
62+ tas_residual_service_with_sync_error,
6263} ;
6364use spar_network:: types:: { NetworkGraph , NodeKind , SwitchType } ;
6465
@@ -275,15 +276,39 @@ impl WcttAnalysis {
275276 // about *when* class-K can transmit, not about
276277 // ownership of bandwidth across competing streams.
277278 let mut cbs_service: Option < ServiceCurve > = None ;
279+ // v0.9.1 NC soundness: a hop is "quantizable" iff its
280+ // service curve does not already account for the
281+ // atomic-frame max-MTU blocking term. The TAS arm and
282+ // the FIFO/Priority fallback arm do not — they undercount
283+ // the per-hop bound by up to one MTU because the
284+ // bytes-level NC kernel treats packets as continuous.
285+ // The CBS arm's `cbs_residual_service` already absorbs
286+ // max-frame blocking via its closed-form latency; the
287+ // preemption arm's `preemption_blocking_term_ps`
288+ // *replaces* the same term with the much smaller
289+ // fragment-time. Both of those leave `quantization_ps = 0`.
290+ let mut quantization_ps: u64 = 0 ;
278291 let svc = if matches ! ( st, SwitchType :: Tsn ) {
279292 let bus_props = instance. properties_for ( * sw_idx) ;
280293 let bus_preemption = get_frame_preemption ( bus_props) . unwrap_or ( false ) ;
281294 if let ( Some ( schedule) , Some ( cos) ) =
282295 ( gate_schedule_for_bus. get ( sw_idx) , stream. cos )
283296 {
284- // Path 1: TAS service curve.
297+ // Path 1: TAS service curve, with the v0.9.1
298+ // gPTP synchronization-error budget ε applied
299+ // (subtracted from the effective open time,
300+ // added to the worst-case gate latency). When
301+ // `Spar_TSN::Sync_Error` is unset on the bus we
302+ // pass ε = 0, which reproduces the v0.8.1
303+ // service curve byte-identically.
285304 let link_rate = link_rate_for_bus. get ( sw_idx) . copied ( ) . unwrap_or ( 0 ) ;
286- let tas_svc = tas_residual_service ( schedule, cos, link_rate) ;
305+ let sync_error_ps = get_sync_error_ps ( bus_props) . unwrap_or ( 0 ) ;
306+ let tas_svc = tas_residual_service_with_sync_error (
307+ schedule,
308+ cos,
309+ link_rate,
310+ sync_error_ps,
311+ ) ;
287312 let ( open_ps, cycle_ps) = schedule. open_fraction ( cos) ;
288313 // open_fraction is reported as a percentage
289314 // (integer-rounded toward zero) for human
@@ -298,7 +323,7 @@ impl WcttAnalysis {
298323 severity : Severity :: Info ,
299324 message : format ! (
300325 "WcttTasGated: stream '{}' (CoS {}) on TSN switch '{}' at hop \
301- {}: open fraction {}% gate latency {} ps",
326+ {}: open fraction {}% gate latency {} ps sync_error {} ps ",
302327 stream_name,
303328 cos. 0 ,
304329 graph
@@ -308,10 +333,16 @@ impl WcttAnalysis {
308333 hop_idx,
309334 open_pct,
310335 gate_latency_ps,
336+ sync_error_ps,
311337 ) ,
312338 path : stream_path. clone ( ) ,
313339 analysis : self . name ( ) . to_string ( ) ,
314340 } ) ;
341+ // TAS service curve: rate = R_link · ρ_K, but the
342+ // link itself still serializes one max-frame per
343+ // hop. Apply atomic-frame quantization at link rate.
344+ let bus_max_frame = get_max_frame_size_bytes ( bus_props) . unwrap_or ( 1518 ) ;
345+ quantization_ps = frame_quantization_ps ( bus_max_frame, link_rate) ;
315346 tas_svc
316347 } else if let Some ( idle_slope_bps) = stream. cbs_idle_slope_bps {
317348 // Path 2: CBS service curve. Stream declares
@@ -496,10 +527,16 @@ impl WcttAnalysis {
496527 continue ;
497528 }
498529 } else {
499- match service_for_bus. get ( sw_idx) {
530+ let s = match service_for_bus. get ( sw_idx) {
500531 Some ( s) => * s,
501532 None => continue ,
502- }
533+ } ;
534+ // FIFO / Priority hop: bytes-level NC undercounts by
535+ // up to one MTU; apply atomic-frame quantization.
536+ let bus_props = instance. properties_for ( * sw_idx) ;
537+ let bus_max_frame = get_max_frame_size_bytes ( bus_props) . unwrap_or ( 1518 ) ;
538+ quantization_ps = frame_quantization_ps ( bus_max_frame, s. rate_bps ) ;
539+ s
503540 } ;
504541
505542 if svc. rate_bps == 0 {
@@ -585,10 +622,32 @@ impl WcttAnalysis {
585622 } ;
586623
587624 // Per-hop delay using the tagged stream's α and the
588- // residual service.
625+ // residual service. Then add `quantization_ps` for
626+ // atomic-frame correctness (zero on CBS / preemption arms,
627+ // computed at link rate on TAS / FIFO arms).
589628 match delay_bound ( & alpha, & residual) {
590629 Ok ( d) => {
591630 total_delay_ps = total_delay_ps. saturating_add ( d) ;
631+ if quantization_ps > 0 {
632+ total_delay_ps = total_delay_ps. saturating_add ( quantization_ps) ;
633+ diags. push ( AnalysisDiagnostic {
634+ severity : Severity :: Info ,
635+ message : format ! (
636+ "WcttFrameQuantization: stream '{}' at hop {} on switch \
637+ '{}': atomic-frame correction +{} ns (max-frame serialization \
638+ at link rate)",
639+ stream_name,
640+ hop_idx,
641+ graph
642+ . node( * sw_idx)
643+ . map( |n| n. name. as_str( ) )
644+ . unwrap_or( "<unknown>" ) ,
645+ quantization_ps / 1_000 ,
646+ ) ,
647+ path : stream_path. clone ( ) ,
648+ analysis : self . name ( ) . to_string ( ) ,
649+ } ) ;
650+ }
592651 }
593652 Err ( NcError :: UnservableFlow ) | Err ( NcError :: UnstableServer ) => {
594653 // delay_bound also returns UnstableServer when
@@ -1357,9 +1416,11 @@ end Net;
13571416 . filter ( |d| d. message . starts_with ( "WcttBound" ) )
13581417 . collect ( ) ;
13591418 assert_eq ! ( info. len( ) , 1 , "exactly one stream expected: {:#?}" , diags) ;
1419+ // v0.9.1 soundness: 12 µs (NC bytes-fluid) + 12.144 µs (atomic-frame
1420+ // quantization at 1 Gbps for 1518-byte MTU) = 24.144 µs.
13601421 assert ! (
1361- info[ 0 ] . message. contains( "12000000 ps" ) ,
1362- "expected 12 us bound, got: {}" ,
1422+ info[ 0 ] . message. contains( "24144000 ps" ) ,
1423+ "expected 24.144 us bound (12 us NC + 12.144 us frame quantization) , got: {}" ,
13631424 info[ 0 ] . message
13641425 ) ;
13651426 }
@@ -1585,9 +1646,11 @@ end Net;
15851646 "expected 3 hops in: {}" ,
15861647 info. message
15871648 ) ;
1649+ // v0.9.1 soundness: 51 µs (NC) + 3 × 12.144 µs (atomic-frame
1650+ // quantization, one per hop) = 87.432 µs.
15881651 assert ! (
1589- info. message. contains( "51000000 ps" ) ,
1590- "expected 51 us bound, got: {}" ,
1652+ info. message. contains( "87432000 ps" ) ,
1653+ "expected 87.432 us bound (51 us NC + 36.432 us quantization) , got: {}" ,
15911654 info. message
15921655 ) ;
15931656 }
@@ -2083,8 +2146,8 @@ end Net;
20832146 . find ( |d| d. message . starts_with ( "WcttBound" ) )
20842147 . unwrap_or_else ( || panic ! ( "expected WcttBound: {:#?}" , diags) ) ;
20852148 assert ! (
2086- bound. message. contains( "29000000 ps" ) ,
2087- "expected 29 us TAS bound, got: {}" ,
2149+ bound. message. contains( "41144000 ps" ) ,
2150+ "expected 41.144 us TAS bound (29 us gated + 12.144 us quantization) , got: {}" ,
20882151 bound. message
20892152 ) ;
20902153 }
@@ -2143,8 +2206,9 @@ end Net;
21432206 . iter ( )
21442207 . find ( |d| d. message . starts_with ( "WcttBound" ) )
21452208 . unwrap ( ) ;
2146- // 12 us = 12_000_000 ps for the ungated single hop.
2147- assert ! ( ungated_bound. message. contains( "12000000 ps" ) ) ;
2209+ // v0.9.1 soundness: 12 µs (NC) + 12.144 µs (atomic-frame
2210+ // quantization, 1518 B at 1 Gbps) = 24.144 µs.
2211+ assert ! ( ungated_bound. message. contains( "24144000 ps" ) ) ;
21482212
21492213 // Same model, but with TSN+GCL applied (50% open for CoS 7).
21502214 // The bound must exceed the ungated 12 us.
@@ -2204,10 +2268,11 @@ end Net;
22042268 . iter ( )
22052269 . find ( |d| d. message . starts_with ( "WcttBound" ) )
22062270 . unwrap ( ) ;
2207- assert ! ( gated_bound. message. contains( "29000000 ps" ) ) ;
2271+ // v0.9.1 soundness: 29 µs gated NC + 12.144 µs quantization = 41.144 µs.
2272+ assert ! ( gated_bound. message. contains( "41144000 ps" ) ) ;
22082273
2209- // Strictly: 29 us > 12 us — the gated bound is more pessimistic
2210- // (and correctly so) than the ungated line-rate bound.
2274+ // Strictly: 41.144 µs > 24.144 µs — the gated bound is more
2275+ // pessimistic (and correctly so) than the ungated line-rate bound.
22112276 }
22122277
22132278 // ── Test 13: TSN switch without GCL still defers ────────────────
0 commit comments