1use std::path::Path;
17
18use serde::Serialize;
19
20use super::decode::{Chip, GgufHeader, Link, Tier, TierKind};
21use super::decode_engine::ddr::{CACHE_POSITIONS, STATE_CHUNKS};
22use super::decode_engine::numerics::ActScaling;
23use super::part::{MemoryKind, PartResources};
24use super::rdu_chip::{Fact, Provenance};
25use super::serving::{
26 best_serving, AttentionMath, Card, ContextClass, ContextMix, Draft, HostLink, Serving, ServingCost, StateFlush, Streams,
27};
28use crate::codegen_sva::fabric_target::Precision;
29
30pub const D5005_DATA_SHEET: &str = "cdrdv2-public.intel.com/691516/ds-pac-d5005-683568-691516.pdf";
32pub const D5005_OFS_GUIDE: &str = "ofs.github.io/ofs-2024.2-1/hw/d5005/dev_guides/afu_dev/ug_dev_afu_d5005";
34pub use super::part::{S10_OVERVIEW, S10_PRODUCT_TABLE};
36pub const PCIE3_SPEC: &str = "PCI-SIG PCI Express Base Specification Revision 3.0";
38
39pub const D5005_SOURCES: &[&str] =
40 &[D5005_DATA_SHEET, D5005_OFS_GUIDE, D5005_FIM_EMIF, S10_OVERVIEW, S10_PRODUCT_TABLE, PCIE3_SPEC];
41
42pub const DSP_BLOCKS: u32 = 5_760;
44pub const M20K_BLOCKS: u32 = 11_721;
46pub const M20K_BITS: u32 = 20_480;
47pub const ALMS: u32 = 933_120;
49pub const ARRAY_MACS_PER_DSP: u32 = 4;
53pub const SCALE_LANE_DSPS: u32 = 3;
56pub const LANE_MIX: f64 = 216.0;
60pub const DDR_CHANNELS: u32 = 4;
62pub const DDR_TRANSFERS_PER_S: f64 = 2.4e9;
63pub const DDR_BYTES_PER_TRANSFER: f64 = 8.0;
64pub const DDR_CHANNEL_BYTES: f64 = (1u64 << 33) as f64;
66pub const D5005_FIM_EMIF: &str = "OFS ofs-d5005 2024.1-1 ipss/mem/ip/emif_ddr4_no_ecc.ip";
68pub const PCIE3_X16_BYTES_PER_S: f64 = 16.0 * 8e9 * 128.0 / 130.0 / 8.0;
70
71pub fn chip() -> Chip {
73 let plan = EnginePlan::standard();
74 Chip {
75 name: "d5005",
76 tiers: vec![on_chip(&plan), memory(plan.beat)],
77 matrix: vec![(
78 Precision::Int8,
79 Fact {
80 value: plan.array_rate(),
81 provenance: plan.clock_hz.provenance,
82 cite: "not published: an int8 operand is two 4-bit digits, two passes of the 4-bit array, so int8 \
83 operations per second (two per multiply-add) equal the array's multiply-adds per second at the \
84 standard engine plan's modeled clock",
85 },
86 )],
87 stage_overhead: None,
88 link: Some(Link {
89 bandwidth: Some(Fact::derived(
90 2.0 * 100e9 / 8.0,
91 "cdrdv2-public.intel.com/691516/ds-pac-d5005-683568-691516.pdf: \"Dual QSFP28 Ports: 2x100G\"; two \
92 ports of 100 Gb/s",
93 )),
94 hop_latency: None,
95 }),
96 power: Some(Fact::published(
97 215.0,
98 "cdrdv2-public.intel.com/691516/ds-pac-d5005-683568-691516.pdf: \"Power 215 W (Under sufficient cooling \
99 capabilities)\"",
100 )),
101 sources: D5005_SOURCES,
102 }
103}
104
105fn memory(beat: Beat) -> Tier {
106 match beat {
107 Beat::Data64 => Tier {
108 kind: TierKind::Ddr,
109 capacity: Fact::derived(
110 DDR_CHANNELS as f64 * DDR_CHANNEL_BYTES,
111 "cdrdv2-public.intel.com/691516/ds-pac-d5005-683568-691516.pdf: \"4x 8GB DDR4-2400 with ECC\"; \
112 ofs.github.io/ofs-2024.2-1/hw/d5005/dev_guides/afu_dev/ug_dev_afu_d5005: \"8GB RDIMM modules organized \
113 as 1 Gb × 72\", 1 Gi words of 64 data bits each, four channels",
114 ),
115 bandwidth: Fact::derived(
116 DDR_CHANNELS as f64 * DDR_TRANSFERS_PER_S * DDR_BYTES_PER_TRANSFER,
117 "cdrdv2-public.intel.com/691516/ds-pac-d5005-683568-691516.pdf: \"4x 8GB DDR4-2400 with ECC\"; four \
118 channels of 2400 MT/s, 8 data bytes a transfer",
119 ),
120 },
121 Beat::Full72 => Tier {
122 kind: TierKind::Ddr,
123 capacity: Fact::derived(
124 DDR_CHANNELS as f64 * DDR_CHANNEL_BYTES * 9.0 / 8.0,
125 "ofs.github.io/ofs-2024.2-1/hw/d5005/dev_guides/afu_dev/ug_dev_afu_d5005: \"8GB RDIMM modules organized \
126 as 1 Gb × 72\"; OFS ofs-d5005 2024.1-1 ipss/mem/ip/emif_ddr4_no_ecc.ip: MEM_DDR4_DQ_WIDTH 72, \
127 CTRL_DDR4_ECC_EN false; 1 Gi words of all 72 bits each, four channels",
128 ),
129 bandwidth: Fact::derived(
130 DDR_CHANNELS as f64 * DDR_TRANSFERS_PER_S * DDR_BYTES_PER_TRANSFER * 9.0 / 8.0,
131 "OFS ofs-d5005 2024.1-1 ipss/mem/ip/emif_ddr4_no_ecc.ip: MEM_DDR4_DQ_WIDTH 72, CTRL_DDR4_ECC_EN false, \
132 MEM_DDR4_SPEEDBIN_ENUM DDR4_SPEEDBIN_2400; ipss/mem/includes/ofs_fim_emif_cfg_pkg.sv: AVMM_DATA_WIDTH 576; \
133 four channels of 2400 MT/s, 9 bytes a transfer",
134 ),
135 },
136 }
137}
138
139fn on_chip(plan: &EnginePlan) -> Tier {
140 let m20k = PartResources::sx2800().memory(MemoryKind::M20k).expect("the SX 2800 carries M20K blocks");
141 Tier {
142 kind: TierKind::Sram,
143 capacity: Fact::derived(m20k.count.value as f64 * m20k.bits.value as f64 / 8.0, m20k.count.cite),
144 bandwidth: Fact {
145 value: m20k.count.value as f64 * m20k.read_bits_per_cycle.value as f64 / 8.0 * plan.clock_hz.value,
146 provenance: plan.clock_hz.provenance.max(m20k.read_bits_per_cycle.provenance),
147 cite: "not published: every M20K block's one widest read port at the standard engine plan's modeled clock",
148 },
149 }
150}
151
152#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
154pub enum Beat {
155 Data64,
157 Full72,
161}
162
163#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
166pub struct EnginePlan {
167 pub clock_hz: Fact<f64>,
169 pub array_dsp_blocks: u32,
171 pub array_soft_macs: Fact<f64>,
173 pub scale_lanes: u32,
175 pub scale_lane_alms: Fact<f64>,
177 pub vector_dsp_blocks: u32,
179 pub beat: Beat,
180 pub state_chunk_lines: u64,
183}
184
185impl EnginePlan {
186 pub fn standard() -> EnginePlan {
191 const VECTOR: u32 = 768;
192 const SOFT: f64 = 24_576.0;
193 let lanes = {
195 let mut l = 0u32;
196 while ((DSP_BLOCKS - VECTOR - SCALE_LANE_DSPS * l) as f64 * ARRAY_MACS_PER_DSP as f64 + SOFT) / LANE_MIX > l as f64 {
197 l += 1;
198 }
199 l
200 };
201 EnginePlan {
202 clock_hz: Fact::modeled(
203 450e6,
204 "not published: the clock a Hyperflex-retimed array is planned to close at; replaced by the fitted \
205 engine's timing",
206 ),
207 array_dsp_blocks: DSP_BLOCKS - VECTOR - SCALE_LANE_DSPS * lanes,
208 array_soft_macs: Fact::modeled(
209 SOFT,
210 "not tabulated: soft multiply-adds of a 4-bit code and an 8-bit code in compressor trees, about 15 ALMs \
211 each over 40% of the device's ALMs; replaced by the engine's synthesized census",
212 ),
213 scale_lanes: lanes,
214 scale_lane_alms: Fact::modeled(
215 650.0,
216 "not tabulated: sixteen radix-4 Booth products of a 6-to-8-bit multiplier and a 17-to-26-bit sum, the \
217 activation scale's 11×8 product, a two-term exact sum over 29 binary places and its rounding to f32; \
218 replaced by the engine's synthesized census",
219 ),
220 vector_dsp_blocks: VECTOR,
221 beat: Beat::Data64,
222 state_chunk_lines: 512,
223 }
224 }
225
226 pub fn full_beat() -> EnginePlan {
228 EnginePlan { beat: Beat::Full72, ..EnginePlan::standard() }
229 }
230
231 pub fn array_rate(&self) -> f64 {
233 (self.array_dsp_blocks as f64 * ARRAY_MACS_PER_DSP as f64 + self.array_soft_macs.value) * self.clock_hz.value
234 }
235
236 pub fn scale_rate(&self) -> f64 {
238 self.scale_lanes as f64 * self.clock_hz.value
239 }
240
241 pub fn dsp_blocks(&self) -> u32 {
243 self.array_dsp_blocks + SCALE_LANE_DSPS * self.scale_lanes + self.vector_dsp_blocks
244 }
245
246 pub fn alms(&self) -> f64 {
248 self.array_soft_macs.value * 15.0 + self.scale_lanes as f64 * self.scale_lane_alms.value
249 }
250
251 pub fn vector_rate(&self) -> f64 {
253 self.vector_dsp_blocks as f64 * self.clock_hz.value
254 }
255}
256
257pub fn card(plan: &EnginePlan) -> Card {
259 assert!(plan.dsp_blocks() <= DSP_BLOCKS, "the plan spends {} DSP blocks of {DSP_BLOCKS}", plan.dsp_blocks());
260 assert!(plan.alms() <= ALMS as f64, "the plan spends {} ALMs of {ALMS}", plan.alms());
261 let weakest = plan.clock_hz.provenance.max(plan.array_soft_macs.provenance);
262 Card {
263 name: "d5005",
264 memory: memory(plan.beat),
265 on_chip: on_chip(plan),
266 streams: streams(plan),
267 array: Fact {
268 value: plan.array_rate(),
269 provenance: weakest,
270 cite: "not published: the plan's array DSP blocks at 4 multiply-adds a cycle plus its soft multiply-adds, \
271 at its modeled clock",
272 },
273 vector: Fact {
274 value: plan.vector_rate(),
275 provenance: plan.clock_hz.provenance,
276 cite: "not published: the plan's f32 DSP blocks, one multiply-add a cycle each (S10-OVERVIEW Table 11: one \
277 single precision multiplier and adder a block), at its modeled clock",
278 },
279 scale: Fact {
280 value: plan.scale_rate(),
281 provenance: plan.clock_hz.provenance.max(plan.scale_lane_alms.provenance),
282 cite: "not published: the plan's block-scale lanes, one lane cycle each a cycle, at its modeled clock",
283 },
284 host: Some(host_link()),
285 sources: D5005_SOURCES,
286 }
287}
288
289#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
291pub struct StreamClocks {
292 pub data_ck: u64,
293 pub end: u64,
294}
295
296impl StreamClocks {
297 pub fn share(self) -> f64 {
299 self.data_ck as f64 / self.end as f64
300 }
301}
302
303#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
309pub struct BeatStreams {
310 pub beat: Beat,
311 pub weights: StreamClocks,
312 pub state: [StreamClocks; STATE_CHUNKS.len()],
313 pub cache: [StreamClocks; CACHE_POSITIONS.len()],
314}
315
316const fn clocks(data_ck: u64, end: u64) -> StreamClocks {
317 StreamClocks { data_ck, end }
318}
319
320pub const DDR_STREAMS: [BeatStreams; 2] = [
322 BeatStreams {
323 beat: Beat::Data64,
324 weights: clocks(262_144, 275_498),
325 state: [clocks(817_152, 1_262_867), clocks(817_152, 962_962), clocks(817_152, 885_772), clocks(817_152, 866_952)],
326 cache: [clocks(131_584, 138_155), clocks(1_049_088, 1_102_936), clocks(4_194_816, 4_411_164)],
327 },
328 BeatStreams {
329 beat: Beat::Full72,
330 weights: clocks(262_144, 275_498),
331 state: [clocks(726_368, 1_122_940), clocks(726_368, 856_196), clocks(726_368, 787_655), clocks(726_368, 770_807)],
332 cache: [clocks(116_992, 123_094), clocks(932_544, 980_425), clocks(3_728_752, 3_920_783)],
333 },
334];
335
336pub fn streams(plan: &EnginePlan) -> Streams {
340 let at = DDR_STREAMS.iter().find(|s| s.beat == plan.beat).expect("the ledger streams both beats");
341 let chunk = STATE_CHUNKS.iter().position(|&c| c == plan.state_chunk_lines).unwrap_or_else(|| {
342 panic!("the plan rewrites a state {} lines at a time; the DDR4 ledger prices {STATE_CHUNKS:?}", plan.state_chunk_lines)
343 });
344 Streams {
345 read: Fact::modeled(
346 at.weights.share(),
347 "not published: the share of DDR4 peak the shell's controllers sustain on one long read, as the JESD79-4 \
348 controller model issues it at the shell's EMIF timing (benchmarks/silicon/d5005/ddr4_efficiency.json, the \
349 weights row of the plan's beat); replaced by the card's measured stream receipt",
350 ),
351 rewrite: Fact::modeled(
352 at.state[chunk].share(),
353 "not published: the share of DDR4 peak the shell's controllers sustain on a memory read and written back a \
354 chunk at a time, as the JESD79-4 controller model issues it at the shell's EMIF timing \
355 (benchmarks/silicon/d5005/ddr4_efficiency.json, the state row of the plan's chunk and beat); replaced by the \
356 card's measured stream receipt",
357 ),
358 append: Fact::modeled(
359 at.cache.iter().map(|c| c.share()).fold(f64::INFINITY, f64::min),
360 "not published: the share of DDR4 peak the shell's controllers sustain on a long read with a row appended \
361 after it, as the JESD79-4 controller model issues it at the shell's EMIF timing \
362 (benchmarks/silicon/d5005/ddr4_efficiency.json, the least of the cache rows of the plan's beat); replaced by \
363 the card's measured stream receipt",
364 ),
365 }
366}
367
368pub fn host_link() -> HostLink {
370 let direction = |cite: &'static str| Fact::derived(PCIE3_X16_BYTES_PER_S, cite);
371 HostLink {
372 capacity: Fact::modeled(
373 (64u64 << 30) as f64,
374 "not published: the host memory a serving pins for the card, 64 GiB; the card's host server is not yet \
375 chosen",
376 ),
377 read: direction(
378 "ofs.github.io/ofs-2024.2-1/hw/d5005/dev_guides/afu_dev/ug_dev_afu_d5005: \"AXI Streaming (AXI-S) interface \
379 to the Host via PCIe Gen3x16\"; PCI-SIG PCI Express Base Specification Revision 3.0: 8.0 GT/s a lane, \
380 128b/130b encoding; 16 lanes host to card",
381 ),
382 write: direction(
383 "ofs.github.io/ofs-2024.2-1/hw/d5005/dev_guides/afu_dev/ug_dev_afu_d5005: \"AXI Streaming (AXI-S) interface \
384 to the Host via PCIe Gen3x16\"; PCI-SIG PCI Express Base Specification Revision 3.0: 8.0 GT/s a lane, \
385 128b/130b encoding; 16 lanes card to host",
386 ),
387 stream: Fact::modeled(
388 0.8,
389 "not published: the share of a PCIe Gen3 x16 direction the card's DMA sustains with 256-byte payloads; \
390 replaced by the card's measured host-stream receipt",
391 ),
392 }
393}
394
395pub const ACCEPTANCE_LEDGER: &str = "benchmarks/results/qwen38_27b.json";
399pub const ACCEPTANCE_POINTER: &str = "/logos/speculative_decode_2026_09_21/b1/accept_rate";
401
402pub fn measured_acceptance(value: f64) -> Fact<f64> {
404 Fact::measured(
405 value,
406 "benchmarks/results/qwen38_27b.json /logos/speculative_decode_2026_09_21/b1/accept_rate: the blk.64 draft \
407 verified against the main model, H100 NVL, 512 prompt tokens and 128 generated",
408 )
409}
410
411pub const DRAFT_HEAD_ROWS: u64 = 32_768;
413pub const KV_PAGE: u64 = 256;
415
416#[derive(Clone, Debug, PartialEq, Serialize)]
418pub struct RooflineRow {
419 pub label: String,
420 pub header: &'static str,
422 pub serving: Serving,
423 pub cost: ServingCost,
424 pub tokens_per_s: f64,
425}
426
427#[derive(Clone, Debug, PartialEq, Serialize)]
428pub struct Roofline {
429 pub card: Card,
430 pub plan: EnginePlan,
431 pub rows: Vec<RooflineRow>,
432 pub full_beat: FullBeat,
434}
435
436#[derive(Clone, Debug, PartialEq, Serialize)]
437pub struct FullBeat {
438 pub card: Card,
439 pub plan: EnginePlan,
440 pub rows: Vec<RooflineRow>,
441}
442
443pub fn ladder(acceptance: f64) -> Vec<(String, &'static str, Serving)> {
445 let base = |limit: u64| Serving {
446 batch: 1,
447 contexts: ContextMix::uniform(limit as f64 / 2.0, limit),
448 kv_page: None,
449 attention: AttentionMath::Vector,
450 kv_bytes: 2.0,
451 state_bytes: 4.0,
452 activation_words: 2,
453 scaling: ActScaling::Sub32,
454 kv_rotated: false,
455 draft: None,
456 flush: StateFlush::EveryPass,
457 host_memory: false,
458 };
459 let per_block = |s: Serving| Serving { scaling: ActScaling::PerBlock, ..s };
460 let f16_state = |s: Serving| Serving { state_bytes: 2.0, ..s };
461 let int8_kv = |s: Serving| Serving { kv_bytes: 1.0 + 2.0 / 256.0, kv_rotated: true, ..s };
463 let lazy = |s: Serving| Serving { flush: StateFlush::Every { tokens: 16 }, ..s };
464 let mtp = |s: Serving| Serving {
465 draft: Some(Draft { tokens: 1, acceptance: vec![measured_acceptance(acceptance)], head_rows: None }),
466 ..s
467 };
468 let fr_spec = |s: Serving| Serving { draft: s.draft.clone().map(|d| Draft { head_rows: Some(DRAFT_HEAD_ROWS), ..d }), ..s };
469 let paged = |s: Serving| Serving { kv_page: Some(KV_PAGE), ..s };
470 let host = |s: Serving| Serving { host_memory: true, ..s };
471 let one_word = |s: Serving| Serving { activation_words: 1, ..s };
472 let q8 = "qwen3.8-27b-q8_0";
473 let q4 = "qwen3.8-27b-q4_k_m";
474 let mut rows = Vec::new();
475 for (ctx, tag) in [(2048u64, "2k"), (8192, "8k")] {
476 let s0 = base(ctx);
477 let sb = per_block(s0.clone());
478 let s1 = f16_state(sb.clone());
479 let s2 = int8_kv(s1.clone());
480 let s3 = lazy(s2.clone());
481 let s4 = mtp(s3.clone());
482 let s5 = one_word(s4.clone());
483 let s6 = fr_spec(s5.clone());
484 let s7 = paged(s6.clone());
485 let s8 = host(s7.clone());
486 for (label, header, s) in [
487 ("Q8_0, f16 KV, f32 state, two-word activations", q8, s0.clone()),
488 ("Q4_K_M", q4, s0),
489 ("+ activations scaled per weight block", q4, sb),
490 ("+ f16 state", q4, s1),
491 ("+ int8 KV, rotated", q4, s2),
492 ("+ state flushed every 16 tokens", q4, s3),
493 ("+ one MTP draft", q4, s4),
494 ("+ one-word activations", q4, s5),
495 ("+ 32k-row drafting head", q4, s6),
496 ("+ paged KV", q4, s7),
497 ("+ host memory as a fifth channel", q4, s8.clone()),
498 ] {
499 rows.push((format!("{tag}: {label}"), header, s));
500 }
501 if tag == "2k" {
502 for (label, header) in [
503 ("Q8_0", "qwen3.8-27b-q8_0"),
504 ("Q6_K", "qwen3.8-27b-q6_k"),
505 ("Q5_K_M", "qwen3.8-27b-q5_k_m"),
506 ("IQ4_XS", "qwen3.8-27b-iq4_xs"),
507 ("Q4_0", "qwen3.8-27b-q4_0"),
508 ("Q3_K_M", "qwen3.8-27b-q3_k_m"),
509 ] {
510 rows.push((format!("{tag}, every trick: {label}"), header, s8.clone()));
511 }
512 }
513 }
514 let every = |contexts: ContextMix, attention: AttentionMath| Serving {
516 contexts,
517 attention,
518 ..host(paged(fr_spec(one_word(mtp(lazy(int8_kv(f16_state(per_block(base(2048))))))))))
519 };
520 for limit in CONTEXT_SWEEP {
521 for (math, name) in [(AttentionMath::Vector, "vector units"), (AttentionMath::Array, "array")] {
522 let label = format!("context {} of {}, attention on the {name}", limit / 2, limit);
523 rows.push((label, q4, every(ContextMix::uniform(limit as f64 / 2.0, limit), math)));
524 }
525 }
526 for (math, name) in [(AttentionMath::Vector, "vector units"), (AttentionMath::Array, "array")] {
527 rows.push((format!("mixed contexts, attention on the {name}"), q4, every(mixed(), math)));
528 }
529 rows
530}
531
532pub const CONTEXT_SWEEP: [u64; 7] = [1024, 4096, 16384, 32768, 65536, 131072, 262144];
534
535pub fn mixed() -> ContextMix {
538 ContextMix {
539 classes: vec![
540 ContextClass { share: 0.70, position: 512.0, limit: 2048 },
541 ContextClass { share: 0.25, position: 4096.0, limit: 8192 },
542 ContextClass { share: 0.05, position: 65536.0, limit: 131072 },
543 ],
544 }
545}
546
547pub fn roofline(decode_dir: &Path) -> Result<Roofline, String> {
549 let ledger: serde_json::Value = serde_json::from_str(
550 &std::fs::read_to_string(decode_dir.join("../../results/qwen38_27b.json")).map_err(|e| format!("{ACCEPTANCE_LEDGER}: {e}"))?,
551 )
552 .map_err(|e| format!("{ACCEPTANCE_LEDGER}: {e}"))?;
553 let acceptance = ledger
554 .pointer(ACCEPTANCE_POINTER)
555 .and_then(|v| v.as_f64())
556 .ok_or_else(|| format!("{ACCEPTANCE_LEDGER}: no number at {ACCEPTANCE_POINTER}"))?;
557 let price = |card: &Card, label: String, header: &'static str, s: Serving| -> Result<RooflineRow, String> {
558 let h = GgufHeader::read_ledger(&decode_dir.join(format!("headers/{header}.json")))?;
559 let cost = best_serving(&h, card, &s).map_err(|e| format!("{label}: {e:?}"))?;
560 let serving = Serving { batch: cost.batch, ..s };
561 Ok(RooflineRow { label, header, tokens_per_s: cost.tokens_per_s(), serving, cost })
562 };
563 let plan = EnginePlan::standard();
564 let standard = card(&plan);
565 let full_plan = EnginePlan::full_beat();
566 let full = card(&full_plan);
567 let rungs = ladder(acceptance);
568 let mut rows = Vec::new();
569 for (label, header, s) in rungs.iter().cloned() {
570 rows.push(price(&standard, label, header, s)?);
571 }
572 let tops = rungs
573 .iter()
574 .filter(|(label, ..)| label.ends_with("host memory as a fifth channel") || label.starts_with("mixed contexts"))
575 .cloned();
576 let mut full_rows = Vec::new();
577 for (label, header, s) in tops {
578 full_rows.push(price(&full, format!("{label}, all 72 bits of every beat"), header, s)?);
579 }
580 Ok(Roofline { card: standard, plan, rows, full_beat: FullBeat { card: full, plan: full_plan, rows: full_rows } })
581}
582
583pub fn weakest(rows: &[RooflineRow]) -> Provenance {
585 rows.iter().map(|r| r.cost.provenance).max().unwrap_or(Provenance::Published)
586}