Skip to content

Commit 2804d14

Browse files
avrabeclaude
andauthored
feat(nc): gPTP ε budget + frame quantization (v0.9.1 soundness pass) (#186)
Two pure-soundness fixes flagged by the post-v0.9.0 reviewer: 1. **gPTP synchronization-error budget (Tier 2 #5)** — adds `Spar_TSN::Sync_Error` (Time, ps; bus + processor) carrying the per-hop 802.1AS-2020 sync error ε. The TAS dispatch in `wctt.rs` now subtracts ε from the effective open time and adds ε to the worst-case gate latency. Without ε the v0.8.1 TAS bounds were *technically unsound* — a frame can miss its window by ε in silicon. ε = 0 (unset) reproduces v0.8.1 byte-identically. Property count: 128 → 129; Spar_TSN per-set count: 6 → 7. 2. **Atomic-frame quantization (Tier 2 #8)** — adds per-hop `ceil(max_frame_bytes · 8e12 / link_rate_bps)` ps on the TAS and FIFO/Priority arms. Bytes-level NC undercounts by up to one MTU per hop because frames are atomic; the kernel was previously slightly optimistic. CBS arm unchanged (service curve absorbs the term). Preemption arm unchanged (replaces with fragment time). New `WcttFrameQuantization` Info diagnostic. Existing wctt test bounds updated from optimistic to sound (e.g. single-hop 1 Gbps: 12 µs → 24.144 µs; gated TAS half-rate: 29 µs → 41.144 µs). Golden fixture `classical_ethernet.expected.json` updated to the sound bounds. Test count: 2772 (was 2759 at v0.9.0; +13 new tests for sync_error accessor, sync_error TAS integration, frame_quantization helper, and quantization integration). Closes Tier 2 reviewer items #5 + #8. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac9cd81 commit 2804d14

6 files changed

Lines changed: 536 additions & 25 deletions

File tree

artifacts/requirements.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,6 +1537,39 @@ artifacts:
15371537
status: implemented
15381538
tags: [network, tsn, wctt, preemption, qbu, v081]
15391539

1540+
# ── v0.9.1 NC soundness pass ──────────────────────────────────────
1541+
1542+
- id: REQ-NETWORK-009
1543+
type: requirement
1544+
title: gPTP synchronization-error budget for TAS bounds
1545+
description: >
1546+
Spar_TSN::Sync_Error (Time, picoseconds; applies to bus,
1547+
processor) carries the per-hop 802.1AS-2020 synchronization
1548+
error ε. The TAS dispatch in wctt.rs subtracts ε from the
1549+
effective open time and adds ε to the worst-case gate latency,
1550+
restoring soundness — current TAS bounds are technically
1551+
unsound without ε because a frame can miss its window by ε in
1552+
silicon. ε = 0 (unset) reproduces v0.8.1 behaviour
1553+
byte-identically. Per the post-v0.9.0 reviewer's Tier 2 #5.
1554+
status: implemented
1555+
tags: [network, tsn, wctt, soundness, gptp, v091]
1556+
1557+
- id: REQ-NETWORK-010
1558+
type: requirement
1559+
title: Atomic-frame quantization in NC per-hop bound
1560+
description: >
1561+
Bytes-level NC undercounts each hop's bound by up to one MTU
1562+
because frames are atomic but the kernel treats packets as
1563+
continuous. wctt.rs adds `ceil(max_frame_bytes · 8e12 /
1564+
link_rate_bps)` picoseconds per hop on the TAS and FIFO/Priority
1565+
arms. The CBS arm's closed-form latency already absorbs the
1566+
max-frame term; the preemption arm replaces it with the
1567+
fragment-time. New `WcttFrameQuantization` Info diagnostic
1568+
records the correction per hop. Per the post-v0.9.0 reviewer's
1569+
Tier 2 #8.
1570+
status: implemented
1571+
tags: [network, wctt, soundness, v091]
1572+
15401573
# ── Track G: spar-insight discrepancy assistant (v0.9.0) ──────────
15411574

15421575
- id: REQ-INSIGHT-001

artifacts/verification.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2040,6 +2040,44 @@ artifacts:
20402040
- type: satisfies
20412041
target: REQ-TSN-004
20422042

2043+
- id: TEST-NC-SOUNDNESS
2044+
type: feature
2045+
title: gPTP ε budget + atomic-frame quantization soundness tests
2046+
description: >
2047+
Unit + integration tests covering the v0.9.1 soundness pass:
2048+
(1) `Spar_TSN::Sync_Error` accessor handles unset / typed
2049+
PropertyExpr / string fallback paths and unit conversions
2050+
(ps, ns, us, ms);
2051+
(2) `tas_residual_service_with_sync_error` shrinks
2052+
open_fraction by ε/cycle and grows worst_case_latency by ε;
2053+
ε equal to or exceeding a window's open_time clamps to
2054+
zero (gate effectively closed); ε = 0 reproduces v0.8.1
2055+
byte-identically;
2056+
(3) `frame_quantization_ps` returns ceil(max_frame · 8e12 /
2057+
link_rate_bps) — 1518 B at 100 Mbps ≈ 121440 ns; 64 B at 1 Gbps
2058+
≈ 512 ns; rate-divides-evenly vs ceil-rounding cases; zero-rate
2059+
guard;
2060+
(4) wctt dispatch integration: TAS / FIFO arms emit
2061+
`WcttFrameQuantization` Info per hop and add the correction
2062+
to the per-hop delay; CBS / preemption arms remain unchanged
2063+
(their service curves already absorb / replace the max-frame
2064+
term); the v0.8.0 `single_hop_classical_ethernet`,
2065+
`multi_hop_chain_aggregates`, and TSN/TAS bound assertions
2066+
were updated to the sound numbers.
2067+
fields:
2068+
method: automated-test
2069+
steps:
2070+
- run: cargo test -p spar-network --lib -- tsn
2071+
- run: cargo test -p spar-analysis --lib -- wctt
2072+
- run: cargo test -p spar-analysis --test wctt_fixtures
2073+
status: passing
2074+
tags: [v0.9.1, network, wctt, soundness, gptp, quantization]
2075+
links:
2076+
- type: satisfies
2077+
target: REQ-NETWORK-009
2078+
- type: satisfies
2079+
target: REQ-NETWORK-010
2080+
20432081
- id: TEST-INSIGHT-DISCREPANCY
20442082
type: feature
20452083
title: spar-insight CTF parser + 5-kind discrepancy detection

crates/spar-analysis/src/wctt.rs

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ use spar_network::extract::{
5656
extract_network_graph, read_forwarding_latency_ps, read_output_rate_bps, read_queue_depth,
5757
};
5858
use 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
};
6364
use 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 ────────────────
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
[
2-
"WcttBound: stream 'data_a (ecu_a → ecu_sink)' end-to-end WCTT 31666668 ps (1 hop)",
3-
"WcttBound: stream 'data_b (ecu_b → ecu_sink)' end-to-end WCTT 31666668 ps (1 hop)"
4-
]
2+
"WcttBound: stream 'data_a (ecu_a → ecu_sink)' end-to-end WCTT 43810668 ps (1 hop)",
3+
"WcttBound: stream 'data_b (ecu_b → ecu_sink)' end-to-end WCTT 43810668 ps (1 hop)",
4+
"WcttFrameQuantization: stream 'data_a (ecu_a → ecu_sink)' at hop 0 on switch 'sw': atomic-frame correction +12144 ns (max-frame serialization at link rate)",
5+
"WcttFrameQuantization: stream 'data_b (ecu_b → ecu_sink)' at hop 0 on switch 'sw': atomic-frame correction +12144 ns (max-frame serialization at link rate)"
6+
]

crates/spar-hir-def/src/standard_properties.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,15 @@ const SPAR_TSN: &[(&str, &str)] = &[
508508
// hi/lo credit slope arithmetic is computed inside the analysis.
509509
// Applies to bus and port.
510510
("Bandwidth_Reservation", "aadlinteger units Data_Rate_Units"),
511+
// Per-hop gPTP (IEEE 802.1AS) synchronization-error budget ε. In
512+
// a real TSN deployment the gate-control list is enforced against
513+
// the local synchronized clock, which differs from the master
514+
// clock by at most ε per hop; v0.9.1 NC soundness pass subtracts
515+
// ε from the effective TAS open time and adds ε to the worst-case
516+
// gate latency so the bounds remain sound under realistic clock
517+
// accuracy. Default unset = 0 (legacy v0.8.1 behaviour).
518+
// Applies to bus and processor.
519+
("Sync_Error", "aadlinteger units Time_Units"),
511520
];
512521

513522
/// Helper: collect properties from a table into the result vector.
@@ -1089,13 +1098,14 @@ mod tests {
10891098
assert!(is_standard_property_set("Spar_TSN"));
10901099

10911100
let props = standard_properties_in_set("Spar_TSN");
1092-
assert_eq!(props.len(), 6);
1101+
assert_eq!(props.len(), 7);
10931102
assert!(props.contains(&"Stream_ID"));
10941103
assert!(props.contains(&"Class_of_Service"));
10951104
assert!(props.contains(&"Gate_Control_List"));
10961105
assert!(props.contains(&"Max_Frame_Size"));
10971106
assert!(props.contains(&"Frame_Preemption"));
10981107
assert!(props.contains(&"Bandwidth_Reservation"));
1108+
assert!(props.contains(&"Sync_Error"));
10991109

11001110
// Each property resolves to its expected type.
11011111
assert_eq!(
@@ -1122,6 +1132,10 @@ mod tests {
11221132
standard_property_type("Spar_TSN", "Bandwidth_Reservation"),
11231133
Some("aadlinteger units Data_Rate_Units")
11241134
);
1135+
assert_eq!(
1136+
standard_property_type("Spar_TSN", "Sync_Error"),
1137+
Some("aadlinteger units Time_Units")
1138+
);
11251139

11261140
// Deliberately-wrong name returns None.
11271141
assert_eq!(standard_property_type("Spar_TSN", "Nonexistent"), None);
@@ -1152,6 +1166,7 @@ mod tests {
11521166
"Max_Frame_Size",
11531167
"Frame_Preemption",
11541168
"Bandwidth_Reservation",
1169+
"Sync_Error",
11551170
] {
11561171
let result = scope.resolve_property(&Name::new("Spar_TSN"), &Name::new(prop_name));
11571172
assert!(
@@ -1195,7 +1210,7 @@ mod tests {
11951210
#[test]
11961211
fn test_all_standard_properties_total_count() {
11971212
let all = all_standard_properties();
1198-
// 12 + 13 + 14 + 14 + 8 + 25 + 4 + 13 + 5 + 4 + 5 + 4 + 1 + 6 = 128
1213+
// 12 + 13 + 14 + 14 + 8 + 25 + 4 + 13 + 5 + 4 + 5 + 4 + 1 + 7 = 129
11991214
// (Timing + Communication + Memory + Deployment + Thread + Programming
12001215
// + Modeling + AADL_Project + Spar_Timing + Spar_Trace + Spar_Network
12011216
// + Spar_Migration + Spar_Power + Spar_TSN)
@@ -1206,7 +1221,8 @@ mod tests {
12061221
// Spar_TSN: +5 for Stream_ID, Class_of_Service, Gate_Control_List,
12071222
// Max_Frame_Size, Frame_Preemption (Track D Phase 2 v0.8.1 c1)
12081223
// +1 for Bandwidth_Reservation (Track D Phase 2 v0.8.1 c3, CBS).
1209-
assert_eq!(all.len(), 128);
1224+
// +1 for Sync_Error (v0.9.1 NC soundness, gPTP ε budget).
1225+
assert_eq!(all.len(), 129);
12101226
}
12111227

12121228
#[test]

0 commit comments

Comments
 (0)