diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index 823773f..f3e8b41 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -3109,15 +3109,37 @@ artifacts: type: requirement title: "TSN config export (802.1Qcw YANG / NETCONF)" description: > - spar shall export synthesized TSN configuration as 802.1Qcw YANG - / NETCONF. ONLY meaningful as the output stage of a working - synthesizer (REQ-TSN-SYNTH-*) — the weakest standalone case in the - analysis. KILL-GATE: premature if no synthesis target consumes the - export. - status: proposed + spar shall export a synthesized 802.1Qbv gate schedule + (GateSchedule, the output of REQ-TSN-SYNTH-QBV-BASE-001) as an + ieee802-dot1q-sched (802.1Qcw) YANG instance document in NETCONF + XML encoding — the deployable artifact a TSN switch consumes via + edit-config, not a network-calculus bound. Each GateWindow maps to + one gate-control-entry (operation-name=set-gate-states, + time-interval-value = duration in ns, gate-states-value = the + allowed-CoS bitmask); the cycle period maps to admin-cycle-time as + the rational cycle_ps / 1e12 s. KILL-GATE (premature if no + synthesis target consumes the export) is now CLEARED: + REQ-TSN-SYNTH-QBV-BASE-001 shipped a GateSchedule producer in + v0.20.0, so export is the genuine output stage of a working + synthesizer. This completes the ingest→synthesize→EXPORT story a + real TSN tool sells (tsnkit/Slate/RTaW/PEGASE emit deployable + configs, not just bounds). Self-certifying oracle: structural + invariants on the emitted document — exactly one gate-control-entry + per window, Σ time-interval-value == cycle, gate-states-value == + window mask, plus a pinned golden snapshot. NO new dependency + (hand-serialized XML String, the to_gcl_blob precedent). The + document is deliberately SIMPLIFIED and not yet schema-valid: the + gate-parameter-table sits directly under interfaces/interface + rather than the 802.1Qcw bridge-port mount, and operation-name + carries a bare `set-gate-states` identityref with no + module-prefixed value. Concrete follow-ups: the bridge-port mount + + prefixed identityref to pass pyang/yanglint, and a CQF/Qch export + path — neither claimed here. + [SOLID — IEEE 802.1Qcw-2023 ieee802-dot1q-sched YANG] + status: implemented tags: [tsn, export, yang, netconf, tier2] fields: - release: v0.21.0 + release: v0.20.0 - id: REQ-TSN-SYNTH-ONLINE-001 type: requirement diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 535158d..3d9b682 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -2301,6 +2301,39 @@ artifacts: - type: satisfies target: REQ-TSN-SYNTH-QBV-BASE-001 + - id: TEST-TSN-EXPORT-YANG + type: feature + title: 802.1Qcw YANG/NETCONF export of a synthesized gate schedule + description: > + Verifies to_qcw_yang, the serializer turning a GateSchedule (the + output of synthesize_gcl, REQ-TSN-SYNTH-QBV-BASE-001) into an + ieee802-dot1q-sched (802.1Qcw) instance document in NETCONF XML. + No external reference tool: the oracle is structural invariants the + emitted document MUST satisfy, checked in spar-network::tsn unit + tests. (1) GOLDEN — a fixed two-window schedule serializes to a + pinned exact XML string (snapshot), so any formatting drift fails; + (2) ONE-ENTRY-PER-WINDOW — the emitted document contains exactly + one per GateWindow, in declaration order; + (3) CYCLE CONSERVATION — Σ time-interval-value over the entries + equals the schedule cycle in ns, and admin-cycle-time encodes + cycle_ps/1e12 s; (4) MASK FIDELITY — each entry's gate-states-value + equals its window's allowed_cos_mask (including the closed mask=0 + guard window); (5) ROUND-TRIP FROM SYNTH — feeding a synthesize_gcl + output straight into to_qcw_yang produces a document whose entry + count and interval sum match the source GateSchedule. Pure string + serialization, NO new dependency (the to_gcl_blob precedent). Full + ieee802-dot1q-sched schema validation (pyang/yanglint) is a noted + follow-up, not asserted here. + fields: + method: automated-test + steps: + - run: "cargo test -p spar-network --lib -- yang" + status: passing + tags: [v0.20.0, network, tsn, export, yang, netconf, oracle] + links: + - type: satisfies + target: REQ-TSN-EXPORT-YANG-001 + - id: TEST-TSN-SYNTH-CQF-BASE type: feature title: CQF synthesis baseline — literature-pinned delay + decoupled admission oracle diff --git a/crates/spar-network/src/tsn.rs b/crates/spar-network/src/tsn.rs index ceea122..7a0df35 100644 --- a/crates/spar-network/src/tsn.rs +++ b/crates/spar-network/src/tsn.rs @@ -1136,6 +1136,82 @@ impl GateSchedule { } out } + + /// Serialize this gate schedule as an IEEE 802.1Qcw `ieee802-dot1q-sched` + /// YANG instance document (NETCONF XML) for the egress port + /// `port_name` — the deployable artifact a TSN switch consumes via + /// `edit-config` (REQ-TSN-EXPORT-YANG-001), not a network-calculus bound. + /// + /// Each [`GateWindow`] becomes one `` with + /// `operation-name=set-gate-states`, `time-interval-value` the window + /// duration in whole nanoseconds (`duration_ps / 1000`, exact for any + /// schedule produced by [`GateSchedule::parse`] or [`synthesize_gcl`]), + /// and `gate-states-value` the window's `allowed_cos_mask` (the closed + /// guard window emits mask 0). The cycle period is the rational + /// `admin-cycle-time` = `cycle_ps / 1e12` seconds. `admin-gate-states` + /// initializes all eight queues open (255). NO new dependency — + /// hand-emitted XML, mirroring [`GateSchedule::to_gcl_blob`]. + pub fn to_qcw_yang(&self, port_name: &str) -> String { + // Picoseconds per second — the denominator of the `admin-cycle-time` + // rational, since every interval in this crate is stored in ps. + const PS_PER_SECOND: u64 = 1_000_000_000_000; + let mut out = String::new(); + out.push_str("\n"); + out.push_str("\n"); + out.push_str(" \n"); + out.push_str(&format!(" {}\n", xml_escape(port_name))); + out.push_str( + " \n", + ); + out.push_str(" true\n"); + out.push_str(" 255\n"); + out.push_str(" \n"); + for (i, w) in self.windows.iter().enumerate() { + // gate-states-value and time-interval-value are DIRECT siblings + // of operation-name in the entry (ieee802-dot1q-sched augments + // the dot1q-types base-gate-control-entries grouping — there is + // no `sgs-params` wrapper, and no `admin-control-list-length` + // leaf: length is enforced by a `must` count constraint). + out.push_str(" \n"); + out.push_str(&format!(" {}\n", i)); + out.push_str(" set-gate-states\n"); + out.push_str(&format!( + " {}\n", + w.duration_ps / 1_000 + )); + out.push_str(&format!( + " {}\n", + w.allowed_cos_mask + )); + out.push_str(" \n"); + } + out.push_str(" \n"); + out.push_str(" \n"); + out.push_str(&format!( + " {}\n", + self.cycle_ps + )); + out.push_str(&format!( + " {}\n", + PS_PER_SECOND + )); + out.push_str(" \n"); + out.push_str(" \n"); + out.push_str(" \n"); + out.push_str("\n"); + out + } +} + +/// Minimal XML text escaping for values embedded in the +/// `ieee802-dot1q-sched` instance document emitted by +/// [`GateSchedule::to_qcw_yang`]. Ampersand is replaced first so the +/// entities introduced by the later replacements are not double-escaped. +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } // ── 802.1Qbv GCL synthesis (REQ-TSN-SYNTH-QBV-BASE-001) ────────────── @@ -2379,4 +2455,171 @@ mod tests { Err(GclSynthError::DuplicateClass { cos: 7 }) )); } + + // ── 802.1Qcw YANG export (REQ-TSN-EXPORT-YANG-001) ─────────────── + + /// A two-window schedule on a 100 ns toy cycle: one 30 ns open window + /// for CoS 2 (mask 0x04) then a 70 ns closed guard window (mask 0). + fn two_window_schedule() -> GateSchedule { + GateSchedule { + windows: vec![ + GateWindow { + offset_ps: 0, + duration_ps: 30_000, + allowed_cos_mask: 0x04, + }, + GateWindow { + offset_ps: 30_000, + duration_ps: 70_000, + allowed_cos_mask: 0x00, + }, + ], + cycle_ps: 100_000, + } + } + + #[test] + fn qcw_yang_golden_snapshot() { + // Pin the EXACT emitted document. Any formatting/structural drift + // (a renamed node, a reordered field, a changed namespace) fails + // here — the snapshot is the strictest oracle for a serializer. + let expected = "\ + + + + eth0 + + true + 255 + + + 0 + set-gate-states + 30 + 4 + + + 1 + set-gate-states + 70 + 0 + + + + 100000 + 1000000000000 + + + + +"; + assert_eq!(two_window_schedule().to_qcw_yang("eth0"), expected); + } + + /// Parse out every `N`. + fn interval_values(doc: &str) -> Vec { + doc.lines() + .filter_map(|l| { + l.trim() + .strip_prefix("") + .and_then(|r| r.strip_suffix("")) + .map(|n| n.parse::().unwrap()) + }) + .collect() + } + + #[test] + fn qcw_yang_one_entry_per_window_and_cycle_conserved() { + let sched = GateSchedule { + windows: vec![ + GateWindow { + offset_ps: 0, + duration_ps: 20_000, + allowed_cos_mask: 0x02, + }, + GateWindow { + offset_ps: 20_000, + duration_ps: 25_000, + allowed_cos_mask: 0x08, + }, + GateWindow { + offset_ps: 45_000, + duration_ps: 55_000, + allowed_cos_mask: 0x00, + }, + ], + cycle_ps: 100_000, + }; + let doc = sched.to_qcw_yang("sw1-p3"); + // exactly one gate-control-entry (and one index) per window + assert_eq!(doc.matches("").count(), 3); + assert_eq!(doc.matches("").count(), 3); + // Σ time-interval-value (ns) == cycle (ns) + let sum_ns: u64 = interval_values(&doc).iter().sum(); + assert_eq!(sum_ns, sched.cycle_ps / 1_000); + // cycle rational: cycle_ps / 1e12 s + assert!(doc.contains("100000")); + assert!(doc.contains("1000000000000")); + } + + #[test] + fn qcw_yang_mask_fidelity_including_guard() { + let sched = GateSchedule { + windows: vec![ + GateWindow { + offset_ps: 0, + duration_ps: 40_000, + allowed_cos_mask: 0x80, // CoS 7 only + }, + GateWindow { + offset_ps: 40_000, + duration_ps: 60_000, + allowed_cos_mask: 0x00, // closed guard + }, + ], + cycle_ps: 100_000, + }; + let doc = sched.to_qcw_yang("p"); + assert!(doc.contains("128")); + // the closed guard window must still appear, as mask 0 + assert!(doc.contains("0")); + } + + #[test] + fn qcw_yang_roundtrips_synthesizer_output() { + // Export a REAL synthesize_gcl output: the document's entry count + // and interval sum must match the source GateSchedule, closing the + // synthesize→export loop end to end. + let link = 1_000_000_000u64; + let cycle_ps = 10_000_000u64; // 10 µs + let demands = vec![ + ClassDemand { + cos: cos(7), + arrival: ArrivalCurve::affine(125, 100_000_000), + deadline_ps: 8_000_000, + }, + ClassDemand { + cos: cos(3), + arrival: ArrivalCurve::affine(250, 200_000_000), + deadline_ps: 9_000_000, + }, + ]; + let sched = synthesize_gcl(&demands, cycle_ps, link).expect("feasible"); + let doc = sched.to_qcw_yang("eth1"); + assert_eq!( + doc.matches("").count(), + sched.windows.len() + ); + let sum_ns: u64 = interval_values(&doc).iter().sum(); + assert_eq!(sum_ns, sched.cycle_ps / 1_000); + } + + #[test] + fn qcw_yang_escapes_port_name() { + // Port names flow into XML text; a `&`/`<` must be entity-escaped + // so the document stays well-formed. + let doc = two_window_schedule().to_qcw_yang("p&\""); + assert!(doc.contains("p&<a>"")); + assert!(!doc.contains("p&")); + } }