Regenerated from committed CSVs by tools/viz/make_viz.py
(Python 3 stdlib only). Source: wp5_campaign_summary.csv (schema 1.1),
-wp6_cost_summary.csv (schema 1.0), wp3_decay_trade.csv. Master seed 0x5AD5C0DECAFE2026,
+wp6_cost_summary.csv (schema 1.1), wp3_decay_trade.csv. Master seed 0x5AD5C0DECAFE2026,
N=500. No timestamps; every number read from the CSVs.
Keep-out figure: simplified research visualization, not a flight safety certificate.
diff --git a/generated/viz/wp6_amortization.svg b/generated/viz/wp6_amortization.svg
index 9d4eafd..0fb73c3 100644
--- a/generated/viz/wp6_amortization.svg
+++ b/generated/viz/wp6_amortization.svg
@@ -1 +1 @@
-
+
diff --git a/generated/viz/wp6_fom_weightings.svg b/generated/viz/wp6_fom_weightings.svg
index e3d74e0..9433d7f 100644
--- a/generated/viz/wp6_fom_weightings.svg
+++ b/generated/viz/wp6_fom_weightings.svg
@@ -1 +1 @@
-
+
diff --git a/generated/viz/wp6_tornado.svg b/generated/viz/wp6_tornado.svg
index 25ff41a..a61e2d3 100644
--- a/generated/viz/wp6_tornado.svg
+++ b/generated/viz/wp6_tornado.svg
@@ -1 +1 @@
-
+
diff --git a/generated/wp13_classC.md b/generated/wp13_classC.md
index 7cf0769..97d9b65 100644
--- a/generated/wp13_classC.md
+++ b/generated/wp13_classC.md
@@ -34,8 +34,8 @@ Steelmanned: a tug architecture also amortizes fixed costs (launch, ground segme
| candidate | cost | source |
|---|---|---|
-| Zenit-2 / SL-16 (catalog_A) | PLACEHOLDER until WP14 | WP14 cost-range itemization |
-| Envisat-class (catalog_C) | PLACEHOLDER until WP14 | WP14 cost-range itemization |
+| Zenit-2 / SL-16 (catalog_A) | itemized low/mid/high ranges | wp6_cost_summary.csv cost_component_musd + campaign_cost_musd rows (WP14) |
+| Envisat-class (catalog_C) | not separately itemized | WP14 ranges are servicer-campaign-scale; a controlled-reentry mission needs its own estimate (open) |
## `wp13_classC.csv` columns
diff --git a/generated/wp14_prioritization.csv b/generated/wp14_prioritization.csv
new file mode 100644
index 0000000..f66c6bf
--- /dev/null
+++ b/generated/wp14_prioritization.csv
@@ -0,0 +1,32 @@
+schema_version,catalog,record_type,metric,weighting,cost_scenario,kit_option,estimate,p05,p50,p95,value_lo,value_hi,units,notes
+1.0,SL-16 / Zenit-2 second stage,fom,fom,spatial_density,,,156.700654,117.539872,156.700654,156.700781,,,kg_per_CU,debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)
+1.0,SL-16 / Zenit-2 second stage,fom,band_weight,spatial_density,,,0.780000,,,,,,normalized,w(h) PLACEHOLDER; cite on fill
+1.0,SL-16 / Zenit-2 second stage,fom,fom,criticality,,,200.898275,150.692144,200.898275,200.898438,,,kg_per_CU,debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)
+1.0,SL-16 / Zenit-2 second stage,fom,band_weight,criticality,,,1.000000,,,,,,normalized,w(h) PLACEHOLDER; cite on fill
+1.0,SL-16 / Zenit-2 second stage,fom,removed_mass_per_mission,,,,36000.000000,,,,,,kg,per mission at baseline N
+1.0,SL-16 / Zenit-2 second stage,cost_per_removal_cu,cost_per_removal,,,,,44.798756,44.798792,59.724414,,,CU_per_removal,"baseline N=4 (the Delta-v-limited amortization minimum, generated/wp6_cost_summary.csv record_type=amortization)"
+1.0,SL-16 / Zenit-2 second stage,cost_per_removal_musd,cost_per_removal,,low,,2.812513,2.812511,2.812513,3.749558,,,MUSD_per_removal,"= cost_per_removal_CU(catalog=SL-16 / Zenit-2 second stage;baseline N=4) x anchor_musd_per_cu[low]=0.062781 MUSD/CU (generated/wp6_cost_summary.csv record_type=currency_anchor_derived); cross-checked against that same file's own record_type=cost_per_removal_musd row for this catalog/scenario -- consistent to display precision (this tool multiplies by the 6dp-rounded anchor while the wp6 row uses the full-precision anchor, so the last printed digit can differ by rounding path)"
+1.0,SL-16 / Zenit-2 second stage,cost_per_removal_musd,cost_per_removal,,mid,,6.924997,6.924992,6.924997,9.232200,,,MUSD_per_removal,"= cost_per_removal_CU(catalog=SL-16 / Zenit-2 second stage;baseline N=4) x anchor_musd_per_cu[mid]=0.154580 MUSD/CU (generated/wp6_cost_summary.csv record_type=currency_anchor_derived); cross-checked against that same file's own record_type=cost_per_removal_musd row for this catalog/scenario -- consistent to display precision (this tool multiplies by the 6dp-rounded anchor while the wp6 row uses the full-precision anchor, so the last printed digit can differ by rounding path)"
+1.0,SL-16 / Zenit-2 second stage,cost_per_removal_musd,cost_per_removal,,high,,18.874985,18.874970,18.874985,25.163568,,,MUSD_per_removal,"= cost_per_removal_CU(catalog=SL-16 / Zenit-2 second stage;baseline N=4) x anchor_musd_per_cu[high]=0.421328 MUSD/CU (generated/wp6_cost_summary.csv record_type=currency_anchor_derived); cross-checked against that same file's own record_type=cost_per_removal_musd row for this catalog/scenario -- consistent to display precision (this tool multiplies by the 6dp-rounded anchor while the wp6 row uses the full-precision anchor, so the last printed digit can differ by rounding path)"
+1.0,SL-8 / Kosmos-3M second stage,fom,fom,spatial_density,,,31.350916,23.516074,31.350916,31.350940,,,kg_per_CU,debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)
+1.0,SL-8 / Kosmos-3M second stage,fom,band_weight,spatial_density,,,1.000000,,,,,,normalized,w(h) PLACEHOLDER; cite on fill
+1.0,SL-8 / Kosmos-3M second stage,fom,fom,criticality,,,21.318623,15.990930,21.318623,21.318639,,,kg_per_CU,debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)
+1.0,SL-8 / Kosmos-3M second stage,fom,band_weight,criticality,,,0.680000,,,,,,normalized,w(h) PLACEHOLDER; cite on fill
+1.0,SL-8 / Kosmos-3M second stage,fom,removed_mass_per_mission,,,,5600.000000,,,,,,kg,per mission at baseline N
+1.0,SL-8 / Kosmos-3M second stage,cost_per_removal_cu,cost_per_removal,,,,,44.655758,44.655793,59.533747,,,CU_per_removal,"baseline N=4 (the Delta-v-limited amortization minimum, generated/wp6_cost_summary.csv record_type=amortization)"
+1.0,SL-8 / Kosmos-3M second stage,cost_per_removal_musd,cost_per_removal,,low,,2.803535,2.803533,2.803535,3.737588,,,MUSD_per_removal,"= cost_per_removal_CU(catalog=SL-8 / Kosmos-3M second stage;baseline N=4) x anchor_musd_per_cu[low]=0.062781 MUSD/CU (generated/wp6_cost_summary.csv record_type=currency_anchor_derived); NOTE: the anchor itself was derived from catalog A's (SL-16) baseline campaign cost (Sec.10 Method), not this catalog's own -- applying it here assumes the CU->MUSD conversion factor is campaign-structure-invariant across catalogs rather than catalog-specific; a stated simplifying assumption, not a hidden one"
+1.0,SL-8 / Kosmos-3M second stage,cost_per_removal_musd,cost_per_removal,,mid,,6.902892,6.902887,6.902892,9.202727,,,MUSD_per_removal,"= cost_per_removal_CU(catalog=SL-8 / Kosmos-3M second stage;baseline N=4) x anchor_musd_per_cu[mid]=0.154580 MUSD/CU (generated/wp6_cost_summary.csv record_type=currency_anchor_derived); NOTE: the anchor itself was derived from catalog A's (SL-16) baseline campaign cost (Sec.10 Method), not this catalog's own -- applying it here assumes the CU->MUSD conversion factor is campaign-structure-invariant across catalogs rather than catalog-specific; a stated simplifying assumption, not a hidden one"
+1.0,SL-8 / Kosmos-3M second stage,cost_per_removal_musd,cost_per_removal,,high,,18.814736,18.814721,18.814736,25.083235,,,MUSD_per_removal,"= cost_per_removal_CU(catalog=SL-8 / Kosmos-3M second stage;baseline N=4) x anchor_musd_per_cu[high]=0.421328 MUSD/CU (generated/wp6_cost_summary.csv record_type=currency_anchor_derived); NOTE: the anchor itself was derived from catalog A's (SL-16) baseline campaign cost (Sec.10 Method), not this catalog's own -- applying it here assumes the CU->MUSD conversion factor is campaign-structure-invariant across catalogs rather than catalog-specific; a stated simplifying assumption, not a hidden one"
+1.0,Envisat-class massive SSO payload,mission_class,not_kit_ranked,,,,,,,,,,,"Envisat-class massive SSO payload has no WP5/WP6 campaign rows (it is not part of the kit-installer Monte Carlo sweep); FoM and cost_per_removal are undefined for this class in this joined view. It is evaluated instead as a separate controlled-reentry mission class (spec WP13 Class C; see generated/wp13_classC.md), not kit-ranked against SL-16/SL-8."
+1.0,SL-16 / Zenit-2 second stage,recommended_kit,,,,edt-candidate-open-risks,,,,,,,,"sail infeasible (135..2155 m2 for the 25-yr guideline, solar max..min); EDT (band 3.0..9.2 yr) subject to T7; libration (T7, Pelaez et al. 2000) and plasma Ne (PLACEHOLDER solar-min/max pair, wp13-literature.md Topic 3) remain open -- an honest EDT candidate, not a closed recommendation"
+1.0,SL-16 / Zenit-2 second stage,sail_area_25yr,,,,,,,,,134.6904,2155.0459,m2,"sail area for the 25-yr IADC guideline (solar max .. solar min); existing area_for_target_years model, see wp3_decay_trade.csv area_for_25yr"
+1.0,SL-16 / Zenit-2 second stage,edt_years,,,,,,,,,2.9844,9.1668,years,"[model: EDT-v1 aligned-dipole, capped-current; eta band |cos i|..cos^2 i; libration T7 OPEN - PLACEHOLDER duty factor]"
+1.0,SL-8 / Kosmos-3M second stage,recommended_kit,,,,sail,,,,,,,,"sail closes (7..115 m2 for the 25-yr guideline, solar max..min); no EDT kit needed for this class"
+1.0,SL-8 / Kosmos-3M second stage,sail_area_25yr,,,,,,,,,7.2046,115.2728,m2,"sail area for the 25-yr IADC guideline (solar max .. solar min); existing area_for_target_years model, see wp3_decay_trade.csv area_for_25yr"
+1.0,SL-8 / Kosmos-3M second stage,edt_years,,,,,,,,,0.6291,3.0259,years,"[model: EDT-v1 aligned-dipole, capped-current; eta band |cos i|..cos^2 i; libration T7 OPEN - PLACEHOLDER duty factor]"
+1.0,Envisat-class massive SSO payload,recommended_kit,,,,controlled-reentry-mission-class,,,,,,,,massive (8211 kg) high-inclination payload -- sail/EDT kit-only deorbit is not the recommended mission class; see generated/wp13_classC.md for the controlled-reentry comparison
+1.0,Envisat-class massive SSO payload,sail_area_25yr,,,,,,,,,61.4390,983.0247,m2,"sail area for the 25-yr IADC guideline (solar max .. solar min); existing area_for_target_years model, see wp3_decay_trade.csv area_for_25yr"
+1.0,Envisat-class massive SSO payload,edt_years,,,,,,,,,5.4814,37.5223,years,"[model: EDT-v1 aligned-dipole, capped-current; eta band |cos i|..cos^2 i; libration T7 OPEN - PLACEHOLDER duty factor]"
+1.0,SL-16 / Zenit-2 second stage,priority_rank,rank,,,,1,,,,,,,mass-dominant FoM under BOTH weightings + McKnight top-20-all-SL-16 citation; see the .md defense paragraph
+1.0,SL-8 / Kosmos-3M second stage,priority_rank,rank,,,,2,,,,,,,"cheap closing sail kit, high per-class population; see the .md defense paragraph"
+1.0,Envisat-class massive SSO payload,priority_rank,rank,,,not-kit-ranked,,,,,,,,"separate controlled-reentry mission class (spec WP13 Class C), not ranked against the two kit-installer classes above; see the .md defense paragraph"
diff --git a/generated/wp14_prioritization.md b/generated/wp14_prioritization.md
new file mode 100644
index 0000000..4f5d276
--- /dev/null
+++ b/generated/wp14_prioritization.md
@@ -0,0 +1,91 @@
+# WP14 Prioritization: target-class ranking (cost + FoM + kit trade)
+
+A pure derived/joined view over three already-committed, already-
+regenerated CSVs -- `generated/wp6_cost_summary.csv` (schema 1.1), `generated/wp13_kit_trade.csv` (schema 1.0), and `generated/wp5_campaign_summary.csv` (schema 1.1). This tool runs no new simulation and adds no new statistics; every number below is read directly from those files, or is a single elementwise arithmetic combination of two already-committed numbers (noted inline wherever that occurs). Regenerated by `tools/prioritization/make_prioritization.py` (Python 3 stdlib only, R9).
+
+## Ranking (spec:265-266)
+
+1. **SL-16 / Zenit-2 second stage** -- mass-dominant FoM under both weightings; the McKnight top-of-ranking class.
+2. **SL-8 / Kosmos-3M second stage** -- cheap closing sail kit, high per-class population.
+3. **Envisat-class massive SSO payload** -- a separate controlled-reentry mission class, NOT kit-ranked against the two classes above.
+
+## Legal/consent gate (D12)
+
+Legal accessibility enters as a gate and metadata flags, never a multiplier (D12, adsc-specification-v5.md section 3): the WP8 compliance engine (`tools/compliance/check_compliance.py`, policy `ADSC-POL-01`) BLOCKs unconsented active debris removal, and this prioritization table consumes its PASS/BLOCK output and metadata flags rather than folding a subjective legal weight into the FoM or into any rank below. No catalog in this document is ranked, up or down, on legal/consent grounds -- consent is a gate, not a scoring input.
+
+## 1. SL-16 / Zenit-2 second stage (priority 1)
+
+### Figure of merit (debris-risk reduction per cost), p50 [kg/CU]
+
+| weighting | FoM p50 | FoM p05..p95 | band weight w(h) |
+|---|---:|---|---:|
+| spatial_density | 156.7 | 117.5..156.7 | 0.78 |
+| criticality | 200.9 | 150.7..200.9 | 1.00 |
+
+### Cost per removal (baseline N=4, the Delta-v-limited amortization minimum)
+
+- CU: p05 44.80 / p50 44.80 / p95 59.72 CU/removal (relative cost units, primary metric).
+- MUSD (external, low-standing -- see the honesty framing quoted in the WP14 evidence-pack cost section), p50 per cost_scenario:
+ - low: p05 2.813 / p50 2.813 / p95 3.750 MUSD/removal
+ - mid: p05 6.925 / p50 6.925 / p95 9.232 MUSD/removal
+ - high: p05 18.875 / p50 18.875 / p95 25.164 MUSD/removal
+
+### Recommended kit
+
+- kit_option: `edt-candidate-open-risks`
+- sail area, 25-yr guideline (solar max..min): 135..2155 m^2 -- impractical at the upper end.
+- EDT deorbit-time band: 3.0..9.2 yr [model: EDT-v1 aligned-dipole, capped-current; eta band |cos i|..cos^2 i; libration T7 OPEN - PLACEHOLDER duty factor]
+- verbatim rationale, quoted from `generated/wp13_kit_trade.csv` (never hand-transcribed): "sail infeasible (135..2155 m2 for the 25-yr guideline, solar max..min); EDT (band 3.0..9.2 yr) subject to T7; libration (T7, Pelaez et al. 2000) and plasma Ne (PLACEHOLDER solar-min/max pair, wp13-literature.md Topic 3) remain open -- an honest EDT candidate, not a closed recommendation"
+
+### Prioritization defense (one paragraph)
+
+The SL-16 class ranks first because its FoM dominates the SL-8 class under BOTH weightings (spatial-density 156.7 vs 31.4 kg/CU p50; criticality 200.9 vs 21.3 kg/CU p50 -- computed from generated/wp6_cost_summary.csv), which is the mass-dominance result the WP6/WP14 cost model already establishes; that dominance is corroborated, not merely asserted, by the published debris-ranking literature: McKnight, D., Witner, R., Letizia, F., Lemmens, S., Anselmo, L., Pardini, C., Rossi, A., Kunstadter, C., et al. (2021), "Identifying the 50 statistically-most-concerning derelict objects in LEO," Acta Astronautica 181, 282-291, doi:10.1016/j.actaastro.2021.01.021 (wp13-literature.md Topic 6): 11 expert teams' consensus top-50 most-concerning LEO objects is dominated by rocket bodies, and the top ~20 are ALL SL-16 (Zenit-2) second stages (average mass of the top-50 objects ~5,295 kg); removing the top-50 would roughly halve LEO collision risk (top-10 ~30% reduction). The class is the ADSC catalog-A anchor for exactly this reason. Its recommended kit is an honest EDT candidate, not a closed recommendation: sail-only does not close the 25-year guideline for this class (135..2155 m^2, impractical), so the electrodynamic-tether branch is carried instead, and that branch remains open on the libration/dynamic-tether-stability question (T7, Pelaez et al. 2000, cited in wp13-literature.md) -- the EDT years band above folds libration in only as a flat PLACEHOLDER duty-cycle knob, never claimed solved. Legal accessibility is a gate, not a factor in this ranking (D12, above).
+
+## 2. SL-8 / Kosmos-3M second stage (priority 2)
+
+### Figure of merit (debris-risk reduction per cost), p50 [kg/CU]
+
+| weighting | FoM p50 | FoM p05..p95 | band weight w(h) |
+|---|---:|---|---:|
+| spatial_density | 31.4 | 23.5..31.4 | 1.00 |
+| criticality | 21.3 | 16.0..21.3 | 0.68 |
+
+### Cost per removal (baseline N=4, the Delta-v-limited amortization minimum)
+
+- CU: p05 44.66 / p50 44.66 / p95 59.53 CU/removal (relative cost units, primary metric).
+- MUSD (external, low-standing; see the honesty framing quoted in the WP14 evidence-pack cost section), p50 per cost_scenario -- note this catalog reuses catalog A's currency anchor (see the CSV notes column for the stated simplifying assumption):
+ - low: p05 2.804 / p50 2.804 / p95 3.738 MUSD/removal
+ - mid: p05 6.903 / p50 6.903 / p95 9.203 MUSD/removal
+ - high: p05 18.815 / p50 18.815 / p95 25.083 MUSD/removal
+
+### Recommended kit
+
+- kit_option: `sail`
+- sail area, 25-yr guideline (solar max..min): 7..115 m^2 -- tens of square meters, a practical kit; no EDT kit needed for this class.
+- verbatim rationale, quoted from `generated/wp13_kit_trade.csv` (never hand-transcribed): "sail closes (7..115 m2 for the 25-yr guideline, solar max..min); no EDT kit needed for this class"
+
+### Prioritization defense (one paragraph)
+
+The SL-8 class ranks second: its FoM is smaller than SL-16's under both weightings (mass-dominated, as above), but it closes today with a cheap, practical sail kit (7..115 m^2 for the 25-year guideline, tens of square meters -- no EDT branch and no open T7 caveat needed), and it represents a much larger per-class population than the SL-16 top-20 concern list: wp13-literature.md Topic 5b: SL-8 (Kosmos-3M) second-stage clusters span three altitude boxes -- Box 3 ~700-800 km (50 R/Bs), Box 2 ~900-1000 km (143 R/Bs), Box 1 ~1500-1600 km (44 R/Bs), inclinations ~74/83 deg -- a combined population an order of magnitude larger than the SL-16 top-20 concern list; the class appears prominently in SL-8-specific ADR mission studies (e.g. ADReS-A, Springer 10.1007/978-3-319-15982-9_3). Cost per removal at its own baseline N is essentially the same as SL-16's in CU terms (44.66 vs 44.80 CU/removal p50), so the batch-amortization economics are not a differentiator between the two classes -- kit simplicity and closure certainty are. Legal accessibility is a gate, not a factor in this ranking (D12, above).
+
+## 3. Envisat-class massive SSO payload (separate mission class, not kit-ranked)
+
+No WP5/WP6 campaign rows exist for this catalog (it is not part of the kit-installer Monte Carlo sweep), so FoM and cost_per_removal are undefined for it here -- stated rather than silently omitted.
+
+### Why it is not a kit choice
+
+- sail area, 25-yr guideline (solar max..min): 61..983 m^2 (context only -- not the recommendation).
+- EDT deorbit-time band: 5.5..37.5 yr (context only -- not the recommendation).
+- kit_option: `controlled-reentry-mission-class`
+- verbatim rationale, quoted from `generated/wp13_kit_trade.csv` (never hand-transcribed): "massive (8211 kg) high-inclination payload -- sail/EDT kit-only deorbit is not the recommended mission class; see generated/wp13_classC.md for the controlled-reentry comparison"
+
+### Prioritization defense (one paragraph)
+
+The Envisat-class massive SSO payload is evaluated as a separate controlled-reentry mission class (spec WP13 Class C), not ranked against SL-16 or SL-8: wp13-literature.md Topic 6 item 1: Envisat (ESA), mass 8,211 kg (Service Module 2,673 + PEB 1,021 + Payload Carrier 2,078 + Fuel 319 + Instruments 2,118 kg), sun-synchronous orbit ~765-800 km, inclination ~98.4-98.55 deg, defunct since April 2012; the archetypal 'too massive / high-casualty-risk-on-uncontrolled-reentry' object cited across the debris-mitigation literature (eoPortal; ESA Earth Online). Controlled-deorbit delta-v, casualty-risk framing and cost detail for this candidate live in `generated/wp13_classC.{csv,md}`, out of scope for this cost/FoM joined view; what belongs here is the negative result itself, stated plainly -- a sail or EDT kit-only deorbit is not the recommended path for an 8,211 kg, ~98.4 deg payload, so it is not assigned a kit-ranked priority number alongside the SL-16/SL-8 classes above. Legal accessibility is a gate, not a factor here either (D12, above).
+
+## Honesty note (T5, criticality weighting citation basis)
+
+The criticality weighting's citation basis is McKnight-anchored at the ~840-850 km peak (McKnight et al. 2021: the top ~20 most-concerning LEO objects are all SL-16/Zenit-2 second stages, ~9 t class, ~850 km -- exactly catalog A's band), which is a real, cited anchor point for catalog A's criticality weight w(h=840)=1.00. But the REST of the criticality weighting table (`CostConfig::weight_table`, the non-peak altitude/weight pairs away from 840-850 km) remains PLACEHOLDER: the McKnight ranking gives a strong, cited reason to weight the ~840-850 km peak high, but it does not by itself supply a citable functional form for how the weight falls off away from that peak, so the shape between anchor points is still an uncited interpolation (T5 open). The dressed-up-mass-sort critique from the WP14 review is acknowledged here, not dismissed: because both weighting tables today have limited dynamic range and removed mass dominates the FoM ratio, a skeptical reader is right to ask whether the 'criticality' weighting is doing real discriminating work beyond re-deriving a mass ranking; strengthening the non-peak citation basis (or accepting the narrower dynamic range as the honest state of the evidence) is the open item, not resolved by this document.
+
+---
+*Generated by `tools/prioritization/make_prioritization.py` from committed artifacts only. This is not legal advice.*
diff --git a/generated/wp6_cost_schema.md b/generated/wp6_cost_schema.md
index 140aea4..35a10e5 100644
--- a/generated/wp6_cost_schema.md
+++ b/generated/wp6_cost_schema.md
@@ -1,29 +1,46 @@
-# WP6 cost/FoM CSV schema (version 1.0)
+# WP6/WP14 cost/FoM CSV schema (version 1.1)
-`generated/wp6_cost_summary.csv` is a long-format table consumed by WP7. It
-consumes the WP5 campaign (schema 1.0): the amortization sweep re-runs the WP5
-engine across the kit-inventory sweep at the fixed master seed, and the
+`generated/wp6_cost_summary.csv` is a long-format table consumed downstream.
+It consumes the WP5 campaign (schema 1.0): the amortization sweep re-runs the
+WP5 engine across the kit-inventory sweep at the fixed master seed, and the
baseline row (n_kits = CampaignConfig::kits_initial) reproduces
`generated/wp5_campaign_runs.csv` exactly. Primary units are RELATIVE cost
-units (CU); **no point-value currency is emitted** (R6/D10).
+units (CU); **no point-value currency is emitted** (R6/D10) -- absolute costs
+appear ONLY as cited low/mid/high ranges (WP14).
+
+Schema 1.1 (WP14) is an ADDITIVE bump over 1.0 (WP6): one new trailing
+column (`cost_scenario`), four new `record_type` values (cost_component_musd
+/ currency_anchor_derived / campaign_cost_musd / cost_per_removal_musd), and
+two new `metric` values under the existing `fom` record_type
+(cost_per_risk_equiv_mass / cost_per_risk_equiv_mass_musd). Every row that
+existed under schema 1.0 is byte-identical in VALUE under 1.1; the only
+change to those rows is the appended blank `cost_scenario` field.
## Columns
| column | meaning |
|---|---|
-| schema_version | WP6 schema id (`1.0`) |
+| schema_version | schema id (`1.1`) |
| catalog | class-level preset name (blank for global rows) |
-| record_type | amortization / fom / tornado / cost_component / currency_anchor |
-| n_kits | kits carried (amortization/cost_component rows); blank otherwise |
-| param | cost parameter (tornado / cost_component rows); blank otherwise |
+| record_type | amortization / fom / tornado / cost_component / currency_anchor / cost_component_musd / currency_anchor_derived / campaign_cost_musd / cost_per_removal_musd |
+| n_kits | kits carried (amortization/cost_component rows) or the baseline N
+ the WP14 derived rows were computed at; blank otherwise |
+| param | cost parameter (tornado / cost_component rows) or the WP14 item id
+ (cost_component_musd rows); blank otherwise |
| weighting | spatial_density / criticality (fom rows); blank otherwise |
| metric | the quantity named in the row |
| estimate | point value (p50 for distributions) |
| p05 / p50 / p95 | percentiles (distribution rows; 0 when not applicable) |
-| units | CU_per_removal / CU / count / ratio / kg / kg_per_CU / normalized / musd_per_cu |
-| notes | provenance / PLACEHOLDER caveats |
+| units | CU_per_removal / CU / count / ratio / kg / kg_per_CU / CU_per_kg /
+ normalized / musd_per_cu / MUSD / MUSD_per_removal / MUSD_per_kg / MUSD_per_yr
+ / MUSD_per_unit / percent-valued units carried in the row's own `units`
+ string (e.g. "pct of cost") |
+| notes | provenance / PLACEHOLDER caveats / WP14 arithmetic strings |
+| cost_scenario | low / mid / high (WP14 rows only); blank for pre-WP14 rows
+ and for rows (e.g. fom / cost_per_risk_equiv_mass in CU) that are not
+ currency-scenario-dependent |
-## record_type rows
+## record_type rows (schema 1.0, unchanged)
- **amortization** (per catalog, per swept N): `cost_per_removal` [CU/removal,
p05/p50/p95], `cost_per_removal_ratio_to_n1` [ratio], `removals` [count p50],
@@ -34,18 +51,71 @@ units (CU); **no point-value currency is emitted** (R6/D10).
- **fom** (per catalog): `removed_mass_per_mission` [kg p50], and per weighting
`fom` [kg/CU, p05/p50/p95] and `band_weight` [normalized w(h)]. FoM =
sum m_i*w(h_i)/C_campaign (spec §4). The two weightings are PLACEHOLDER and
- disagree on band priority (T5).
+ disagree on band priority (T5). WP14 adds, per weighting: `cost_per_ risk_equiv_mass` [CU_per_kg, p05/p50/p95] = C_campaign/sum(m_i*w(h_i)),
+ the exact inverse orientation: each percentile is the reciprocal of the
+ FoM percentile BY CONSTRUCTION (NOT the percentile of per-run
+ reciprocals -- order statistics interpolate, so the two are not the same
+ computation), with p05/p95 swapped since 1/x is decreasing; and
+ `cost_per_risk_equiv_mass_musd` [MUSD_per_kg, cost_scenario=mid only, to
+ bound row count] = the same quantity converted via the mid-scenario anchor.
- **cost_component** (per catalog, baseline N): C_dev / C_bus / C_kit_total /
C_launch / C_ops [CU] at the median mission time.
- **tornado** (representative catalog, baseline N): per cost parameter,
`cost_per_removal_swing` [CU] and `fom_swing` [kg/CU] under a +/- one-at-a-
time perturbation; rows are sorted by cost/removal swing.
-- **currency_anchor** (global): `cu_to_musd_range` -- a PLACEHOLDER cited
- range (estimate/p05 = low, p95 = high). WP6 never emits a point-value dollar
- figure; a filled cited range is a WP7 deliverable.
+- **currency_anchor** (global): `cu_to_musd_range` -- unfilled pre-WP14
+ sentinel (CostConfig::cu_to_musd_low/high, both 0.0; SUPERSEDED, see
+ currency_anchor_derived below). Retained byte-identical for schema
+ continuity; it never carries a point-value dollar figure.
+
+## record_type rows (schema 1.1, new -- WP14)
+
+- **cost_component_musd** (global, one row per WP14 itemized cost row x
+ cost_scenario): `metric=cost`, `estimate`/`p50` = the row's value at that
+ scenario, `units` = the item's own unit (MUSD / MUSD/yr / MUSD/unit / a
+ percentage unit), `param` = the item id, `notes` = the full cited source
+ note (D10: every row is sourced-or-PLACEHOLDER; a PLACEHOLDER row's note
+ ends `[PLACEHOLDER]` and its value is 0, never fabricated).
+- **currency_anchor_derived** (global, one row per cost_scenario):
+ `metric=anchor_musd_per_cu` [musd_per_cu] = core_5_component /
+ C_campaign_CU_p50 at the baseline N (catalog A). The core is EXACTLY the
+ 5 CU-mapped components (Sec.10 Method): development +
+ manufacturing_bus_unit + kit_unit_scaled_9t x baseline-N-kits + C_launch
+ (low=rideshare card low, mid=rideshare upper, high=dedicated Electron --
+ width preserved, not narrowed) + operations_3yr. ground_segment_3yr,
+ licensing_fcc_application, licensing_fcc_annual, and the PLACEHOLDER
+ ssa_tracking row are ADDITIVE and excluded from this core (see
+ campaign_cost_musd below); so are the insurance/contingency percentage
+ rows. The `notes` field prints the full arithmetic string, numerator AND
+ denominator, so the figure is independently auditable from the row alone.
+- **campaign_cost_musd** (global, per cost_scenario), four separately
+ visible rows so the anchor's basis is auditable: `metric=core_5_component`
+ = the identical numerator as currency_anchor_derived, in MUSD;
+ `metric=additives` = ground_segment_3yr + licensing_fcc_application +
+ licensing_fcc_annual x3 yr + ssa_tracking (PLACEHOLDER, 0), outside the CU
+ model and outside the anchor core; `metric=base_total` = core_5_component
+ + additives; `metric=with_reserves` = base_total x (1 +
+ contingency_reserve_pct/100) + insurance_line, where insurance_line =
+ (insurance_launch_plus_1yr_pct/100) x insured_value and insured_value =
+ manufacturing_bus_unit + kit_unit_scaled_9t x baseline-N-kits + C_launch
+ (asset+launch scale only -- insurance is an ADDITIVE line, not a
+ multiplier over the multi-year opex folded into base_total); `notes`
+ carries the formula for each row.
+- **cost_per_removal_musd** (catalog A, baseline N, per cost_scenario):
+ `metric=cost_per_removal` [MUSD_per_removal, p05/p50/p95] = the existing
+ cost_per_removal CU percentiles at the baseline N times that scenario's
+ anchor_musd_per_cu.
+
+## Honesty framing (D10, carried into every WP14 currency row)
+
+External absolute-cost estimation by an unaffiliated author is inherently
+low-standing; agencies re-run with their own parametric models; the relative
+CU results remain primary. No point-value currency figure is emitted:
+absolute costs appear ONLY as low/mid/high cited ranges.
-## Future visualization / evidence (WP7) — not implemented here
+## Future visualization / evidence -- not implemented here
The schema supports, without change: amortization curves, FoM-under-weightings
-bars with percentile whiskers, tornado bars, and the cost-component breakdown.
-WP6 emits none of these charts and predicts no absolute cost.
+bars with percentile whiskers, tornado bars, the cost-component breakdown, and
+the WP14 itemized/anchor/campaign-cost tables. This module emits none of
+these charts.
diff --git a/generated/wp6_cost_summary.csv b/generated/wp6_cost_summary.csv
index 713bc4c..f405572 100644
--- a/generated/wp6_cost_summary.csv
+++ b/generated/wp6_cost_summary.csv
@@ -1,96 +1,158 @@
-schema_version,catalog,record_type,n_kits,param,weighting,metric,estimate,p05,p50,p95,units,notes
-1.0,SL-16 / Zenit-2 second stage,amortization,1,,,cost_per_removal,157.068554,157.068476,157.068554,159.068573,CU_per_removal,""
-1.0,SL-16 / Zenit-2 second stage,amortization,1,,,cost_per_removal_ratio_to_n1,1.000000,0.000000,1.000000,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-16 / Zenit-2 second stage,amortization,1,,,removals,1.000000,0.000000,1.000000,0.000000,count,""
-1.0,SL-16 / Zenit-2 second stage,amortization,1,,,campaign_cost,157.068554,0.000000,157.068554,0.000000,CU,""
-1.0,SL-16 / Zenit-2 second stage,amortization,2,,,cost_per_removal,82.222051,82.221995,82.222051,83.222100,CU_per_removal,""
-1.0,SL-16 / Zenit-2 second stage,amortization,2,,,cost_per_removal_ratio_to_n1,0.523479,0.000000,0.523479,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-16 / Zenit-2 second stage,amortization,2,,,removals,2.000000,0.000000,2.000000,0.000000,count,""
-1.0,SL-16 / Zenit-2 second stage,amortization,2,,,campaign_cost,164.444102,0.000000,164.444102,0.000000,CU,""
-1.0,SL-16 / Zenit-2 second stage,amortization,3,,,cost_per_removal,57.273224,57.273171,57.273224,57.939899,CU_per_removal,""
-1.0,SL-16 / Zenit-2 second stage,amortization,3,,,cost_per_removal_ratio_to_n1,0.364638,0.000000,0.364638,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-16 / Zenit-2 second stage,amortization,3,,,removals,3.000000,0.000000,3.000000,0.000000,count,""
-1.0,SL-16 / Zenit-2 second stage,amortization,3,,,campaign_cost,171.819673,0.000000,171.819673,0.000000,CU,""
-1.0,SL-16 / Zenit-2 second stage,amortization,4,,,cost_per_removal,44.798792,44.798756,44.798792,59.724414,CU_per_removal,""
-1.0,SL-16 / Zenit-2 second stage,amortization,4,,,cost_per_removal_ratio_to_n1,0.285218,0.000000,0.285218,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-16 / Zenit-2 second stage,amortization,4,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-16 / Zenit-2 second stage,amortization,4,,,campaign_cost,179.195140,0.000000,179.195140,0.000000,CU,""
-1.0,SL-16 / Zenit-2 second stage,amortization,5,,,cost_per_removal,46.137160,36.914113,46.137160,61.508947,CU_per_removal,""
-1.0,SL-16 / Zenit-2 second stage,amortization,5,,,cost_per_removal_ratio_to_n1,0.293739,0.000000,0.293739,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-16 / Zenit-2 second stage,amortization,5,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-16 / Zenit-2 second stage,amortization,5,,,campaign_cost,184.548865,0.000000,184.548865,0.000000,CU,""
-1.0,SL-16 / Zenit-2 second stage,amortization,6,,,cost_per_removal,47.475560,37.984833,47.475560,63.293481,CU_per_removal,""
-1.0,SL-16 / Zenit-2 second stage,amortization,6,,,cost_per_removal_ratio_to_n1,0.302260,0.000000,0.302260,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-16 / Zenit-2 second stage,amortization,6,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-16 / Zenit-2 second stage,amortization,6,,,campaign_cost,189.902465,0.000000,189.902465,0.000000,CU,""
-1.0,SL-16 / Zenit-2 second stage,amortization,7,,,cost_per_removal,48.813960,39.055553,48.813960,65.078014,CU_per_removal,""
-1.0,SL-16 / Zenit-2 second stage,amortization,7,,,cost_per_removal_ratio_to_n1,0.310781,0.000000,0.310781,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-16 / Zenit-2 second stage,amortization,7,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-16 / Zenit-2 second stage,amortization,7,,,campaign_cost,195.256065,0.000000,195.256065,0.000000,CU,""
-1.0,SL-16 / Zenit-2 second stage,amortization,8,,,cost_per_removal,50.152360,40.126273,50.152360,66.862547,CU_per_removal,""
-1.0,SL-16 / Zenit-2 second stage,amortization,8,,,cost_per_removal_ratio_to_n1,0.319302,0.000000,0.319302,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-16 / Zenit-2 second stage,amortization,8,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-16 / Zenit-2 second stage,amortization,8,,,campaign_cost,200.609665,0.000000,200.609665,0.000000,CU,""
-1.0,SL-16 / Zenit-2 second stage,fom,,,,removed_mass_per_mission,36000.000000,0.000000,36000.000000,0.000000,kg,"per mission at baseline N"
-1.0,SL-16 / Zenit-2 second stage,fom,,,spatial_density,fom,156.700654,117.539872,156.700654,156.700781,kg_per_CU,"debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)"
-1.0,SL-16 / Zenit-2 second stage,fom,,,spatial_density,band_weight,0.780000,0.780000,0.780000,0.780000,normalized,"w(h) PLACEHOLDER; cite on fill"
-1.0,SL-16 / Zenit-2 second stage,fom,,,criticality,fom,200.898275,150.692144,200.898275,200.898438,kg_per_CU,"debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)"
-1.0,SL-16 / Zenit-2 second stage,fom,,,criticality,band_weight,1.000000,1.000000,1.000000,1.000000,normalized,"w(h) PLACEHOLDER; cite on fill"
-1.0,SL-16 / Zenit-2 second stage,cost_component,4,C_dev,,component_cost,100.000000,0.000000,100.000000,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-16 / Zenit-2 second stage,cost_component,4,C_bus,,component_cost,30.291410,0.000000,30.291410,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-16 / Zenit-2 second stage,cost_component,4,C_kit_total,,component_cost,16.000000,0.000000,16.000000,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-16 / Zenit-2 second stage,cost_component,4,C_launch,,component_cost,24.816000,0.000000,24.816000,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-16 / Zenit-2 second stage,cost_component,4,C_ops,,component_cost,8.087730,0.000000,8.087730,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-8 / Kosmos-3M second stage,amortization,1,,,cost_per_removal,156.590154,156.590067,156.590154,158.590163,CU_per_removal,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,1,,,cost_per_removal_ratio_to_n1,1.000000,0.000000,1.000000,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-8 / Kosmos-3M second stage,amortization,1,,,removals,1.000000,0.000000,1.000000,0.000000,count,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,1,,,campaign_cost,156.590154,0.000000,156.590154,0.000000,CU,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,2,,,cost_per_removal,81.967258,81.967198,81.967258,82.967274,CU_per_removal,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,2,,,cost_per_removal_ratio_to_n1,0.523451,0.000000,0.523451,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-8 / Kosmos-3M second stage,amortization,2,,,removals,2.000000,0.000000,2.000000,0.000000,count,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,2,,,campaign_cost,163.934516,0.000000,163.934516,0.000000,CU,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,3,,,cost_per_removal,57.092962,57.092904,57.092962,57.759636,CU_per_removal,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,3,,,cost_per_removal_ratio_to_n1,0.364601,0.000000,0.364601,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-8 / Kosmos-3M second stage,amortization,3,,,removals,3.000000,0.000000,3.000000,0.000000,count,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,3,,,campaign_cost,171.278887,0.000000,171.278887,0.000000,CU,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,4,,,cost_per_removal,44.655793,44.655758,44.655793,59.533747,CU_per_removal,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,4,,,cost_per_removal_ratio_to_n1,0.285176,0.000000,0.285176,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-8 / Kosmos-3M second stage,amortization,4,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,4,,,campaign_cost,178.623146,0.000000,178.623146,0.000000,CU,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,5,,,cost_per_removal,45.986360,36.793479,45.986360,61.307881,CU_per_removal,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,5,,,cost_per_removal_ratio_to_n1,0.293673,0.000000,0.293673,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-8 / Kosmos-3M second stage,amortization,5,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,5,,,campaign_cost,183.945668,0.000000,183.945668,0.000000,CU,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,6,,,cost_per_removal,47.316960,37.857959,47.316960,63.082014,CU_per_removal,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,6,,,cost_per_removal_ratio_to_n1,0.302171,0.000000,0.302171,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-8 / Kosmos-3M second stage,amortization,6,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,6,,,campaign_cost,189.268068,0.000000,189.268068,0.000000,CU,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,7,,,cost_per_removal,48.647560,38.922439,48.647560,64.856147,CU_per_removal,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,7,,,cost_per_removal_ratio_to_n1,0.310668,0.000000,0.310668,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-8 / Kosmos-3M second stage,amortization,7,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,7,,,campaign_cost,194.590468,0.000000,194.590468,0.000000,CU,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,8,,,cost_per_removal,49.978160,39.986919,49.978160,66.630281,CU_per_removal,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,8,,,cost_per_removal_ratio_to_n1,0.319165,0.000000,0.319165,0.000000,ratio,"p50 relative to sweep min N"
-1.0,SL-8 / Kosmos-3M second stage,amortization,8,,,removals,4.000000,0.000000,4.000000,0.000000,count,""
-1.0,SL-8 / Kosmos-3M second stage,amortization,8,,,campaign_cost,199.912868,0.000000,199.912868,0.000000,CU,""
-1.0,SL-8 / Kosmos-3M second stage,fom,,,,removed_mass_per_mission,5600.000000,0.000000,5600.000000,0.000000,kg,"per mission at baseline N"
-1.0,SL-8 / Kosmos-3M second stage,fom,,,spatial_density,fom,31.350916,23.516074,31.350916,31.350940,kg_per_CU,"debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)"
-1.0,SL-8 / Kosmos-3M second stage,fom,,,spatial_density,band_weight,1.000000,1.000000,1.000000,1.000000,normalized,"w(h) PLACEHOLDER; cite on fill"
-1.0,SL-8 / Kosmos-3M second stage,fom,,,criticality,fom,21.318623,15.990930,21.318623,21.318639,kg_per_CU,"debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)"
-1.0,SL-8 / Kosmos-3M second stage,fom,,,criticality,band_weight,0.680000,0.680000,0.680000,0.680000,normalized,"w(h) PLACEHOLDER; cite on fill"
-1.0,SL-8 / Kosmos-3M second stage,cost_component,4,C_dev,,component_cost,100.000000,0.000000,100.000000,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-8 / Kosmos-3M second stage,cost_component,4,C_bus,,component_cost,30.291410,0.000000,30.291410,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-8 / Kosmos-3M second stage,cost_component,4,C_kit_total,,component_cost,16.000000,0.000000,16.000000,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-8 / Kosmos-3M second stage,cost_component,4,C_launch,,component_cost,24.244000,0.000000,24.244000,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-8 / Kosmos-3M second stage,cost_component,4,C_ops,,component_cost,8.087736,0.000000,8.087736,0.000000,CU,"baseline breakdown at median mission time"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_dev_cu,,cost_per_removal_swing,15.000000,0.000000,15.000000,0.000000,CU_per_removal,"low=37.2988;high=52.2988 (+/-30% one-at-a-time)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_dev_cu,,fom_swing,53.981127,0.000000,53.981127,0.000000,kg_per_CU,"low=188.20985;high=134.22872 (spatial FoM)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_bus_cu,,cost_per_removal_swing,4.543711,0.000000,4.543711,0.000000,CU_per_removal,"low=42.5269;high=47.0706 (+/-30% one-at-a-time)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_bus_cu,,fom_swing,15.934322,0.000000,15.934322,0.000000,kg_per_CU,"low=165.07185;high=149.13753 (spatial FoM)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_launch_cu_per_kg,,cost_per_removal_swing,3.722400,0.000000,3.722400,0.000000,CU_per_removal,"low=42.9376;high=46.6600 (+/-30% one-at-a-time)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_launch_cu_per_kg,,fom_swing,13.043009,0.000000,13.043009,0.000000,kg_per_CU,"low=163.49310;high=150.45009 (spatial FoM)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_kit_cu,,cost_per_removal_swing,2.400000,0.000000,2.400000,0.000000,CU_per_removal,"low=43.5988;high=45.9988 (+/-30% one-at-a-time)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_kit_cu,,fom_swing,8.400932,0.000000,8.400932,0.000000,kg_per_CU,"low=161.01364;high=152.61270 (spatial FoM)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_ops_cu_per_day,,cost_per_removal_swing,1.213164,0.000000,1.213164,0.000000,CU_per_removal,"low=44.1922;high=45.4054 (+/-30% one-at-a-time)"
-1.0,SL-16 / Zenit-2 second stage,tornado,,c_ops_cu_per_day,,fom_swing,4.244275,0.000000,4.244275,0.000000,kg_per_CU,"low=158.85153;high=154.60725 (spatial FoM)"
-1.0,,currency_anchor,,,,cu_to_musd_range,0.000000,0.000000,0.000000,0.000000,musd_per_cu,"PLACEHOLDER: fill a CITED range in WP7; no point-value dollar figure is claimed"
+schema_version,catalog,record_type,n_kits,param,weighting,metric,estimate,p05,p50,p95,units,notes,cost_scenario
+1.1,SL-16 / Zenit-2 second stage,amortization,1,,,cost_per_removal,157.068554,157.068476,157.068554,159.068573,CU_per_removal,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,1,,,cost_per_removal_ratio_to_n1,1.000000,0.000000,1.000000,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-16 / Zenit-2 second stage,amortization,1,,,removals,1.000000,0.000000,1.000000,0.000000,count,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,1,,,campaign_cost,157.068554,0.000000,157.068554,0.000000,CU,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,2,,,cost_per_removal,82.222051,82.221995,82.222051,83.222100,CU_per_removal,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,2,,,cost_per_removal_ratio_to_n1,0.523479,0.000000,0.523479,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-16 / Zenit-2 second stage,amortization,2,,,removals,2.000000,0.000000,2.000000,0.000000,count,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,2,,,campaign_cost,164.444102,0.000000,164.444102,0.000000,CU,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,3,,,cost_per_removal,57.273224,57.273171,57.273224,57.939899,CU_per_removal,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,3,,,cost_per_removal_ratio_to_n1,0.364638,0.000000,0.364638,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-16 / Zenit-2 second stage,amortization,3,,,removals,3.000000,0.000000,3.000000,0.000000,count,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,3,,,campaign_cost,171.819673,0.000000,171.819673,0.000000,CU,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,4,,,cost_per_removal,44.798792,44.798756,44.798792,59.724414,CU_per_removal,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,4,,,cost_per_removal_ratio_to_n1,0.285218,0.000000,0.285218,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-16 / Zenit-2 second stage,amortization,4,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,4,,,campaign_cost,179.195140,0.000000,179.195140,0.000000,CU,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,5,,,cost_per_removal,46.137160,36.914113,46.137160,61.508947,CU_per_removal,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,5,,,cost_per_removal_ratio_to_n1,0.293739,0.000000,0.293739,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-16 / Zenit-2 second stage,amortization,5,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,5,,,campaign_cost,184.548865,0.000000,184.548865,0.000000,CU,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,6,,,cost_per_removal,47.475560,37.984833,47.475560,63.293481,CU_per_removal,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,6,,,cost_per_removal_ratio_to_n1,0.302260,0.000000,0.302260,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-16 / Zenit-2 second stage,amortization,6,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,6,,,campaign_cost,189.902465,0.000000,189.902465,0.000000,CU,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,7,,,cost_per_removal,48.813960,39.055553,48.813960,65.078014,CU_per_removal,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,7,,,cost_per_removal_ratio_to_n1,0.310781,0.000000,0.310781,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-16 / Zenit-2 second stage,amortization,7,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,7,,,campaign_cost,195.256065,0.000000,195.256065,0.000000,CU,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,8,,,cost_per_removal,50.152360,40.126273,50.152360,66.862547,CU_per_removal,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,8,,,cost_per_removal_ratio_to_n1,0.319302,0.000000,0.319302,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-16 / Zenit-2 second stage,amortization,8,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-16 / Zenit-2 second stage,amortization,8,,,campaign_cost,200.609665,0.000000,200.609665,0.000000,CU,"",
+1.1,SL-16 / Zenit-2 second stage,fom,,,,removed_mass_per_mission,36000.000000,0.000000,36000.000000,0.000000,kg,"per mission at baseline N",
+1.1,SL-16 / Zenit-2 second stage,fom,,,spatial_density,fom,156.700654,117.539872,156.700654,156.700781,kg_per_CU,"debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)",
+1.1,SL-16 / Zenit-2 second stage,fom,,,spatial_density,band_weight,0.780000,0.780000,0.780000,0.780000,normalized,"w(h) PLACEHOLDER; cite on fill",
+1.1,SL-16 / Zenit-2 second stage,fom,,,spatial_density,cost_per_risk_equiv_mass,0.006382,0.006382,0.006382,0.008508,CU_per_kg,"= C_campaign/sum(m_i*w(h_i)) = 1/fom per the same per-run FoM machinery (weights not recomputed); p50 is the reciprocal of the fom_p50 percentile BY CONSTRUCTION, NOT the percentile of per-run reciprocals (order statistics interpolate); p05/p95 are reciprocals of fom_p95/fom_p05 (order reverses for a decreasing transform)",
+1.1,SL-16 / Zenit-2 second stage,fom,,,spatial_density,cost_per_risk_equiv_mass_musd,0.000986,0.000986,0.000986,0.001315,MUSD_per_kg,"= cost_per_risk_equiv_mass[CU_per_kg] x anchor_musd_per_cu[cost_scenario=mid]; mid-scenario anchor only to bound row count",mid
+1.1,SL-16 / Zenit-2 second stage,fom,,,criticality,fom,200.898275,150.692144,200.898275,200.898438,kg_per_CU,"debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)",
+1.1,SL-16 / Zenit-2 second stage,fom,,,criticality,band_weight,1.000000,1.000000,1.000000,1.000000,normalized,"w(h) PLACEHOLDER; cite on fill",
+1.1,SL-16 / Zenit-2 second stage,fom,,,criticality,cost_per_risk_equiv_mass,0.004978,0.004978,0.004978,0.006636,CU_per_kg,"= C_campaign/sum(m_i*w(h_i)) = 1/fom per the same per-run FoM machinery (weights not recomputed); p50 is the reciprocal of the fom_p50 percentile BY CONSTRUCTION, NOT the percentile of per-run reciprocals (order statistics interpolate); p05/p95 are reciprocals of fom_p95/fom_p05 (order reverses for a decreasing transform)",
+1.1,SL-16 / Zenit-2 second stage,fom,,,criticality,cost_per_risk_equiv_mass_musd,0.000769,0.000769,0.000769,0.001026,MUSD_per_kg,"= cost_per_risk_equiv_mass[CU_per_kg] x anchor_musd_per_cu[cost_scenario=mid]; mid-scenario anchor only to bound row count",mid
+1.1,SL-16 / Zenit-2 second stage,cost_component,4,C_dev,,component_cost,100.000000,0.000000,100.000000,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-16 / Zenit-2 second stage,cost_component,4,C_bus,,component_cost,30.291410,0.000000,30.291410,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-16 / Zenit-2 second stage,cost_component,4,C_kit_total,,component_cost,16.000000,0.000000,16.000000,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-16 / Zenit-2 second stage,cost_component,4,C_launch,,component_cost,24.816000,0.000000,24.816000,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-16 / Zenit-2 second stage,cost_component,4,C_ops,,component_cost,8.087730,0.000000,8.087730,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-8 / Kosmos-3M second stage,amortization,1,,,cost_per_removal,156.590154,156.590067,156.590154,158.590163,CU_per_removal,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,1,,,cost_per_removal_ratio_to_n1,1.000000,0.000000,1.000000,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-8 / Kosmos-3M second stage,amortization,1,,,removals,1.000000,0.000000,1.000000,0.000000,count,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,1,,,campaign_cost,156.590154,0.000000,156.590154,0.000000,CU,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,2,,,cost_per_removal,81.967258,81.967198,81.967258,82.967274,CU_per_removal,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,2,,,cost_per_removal_ratio_to_n1,0.523451,0.000000,0.523451,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-8 / Kosmos-3M second stage,amortization,2,,,removals,2.000000,0.000000,2.000000,0.000000,count,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,2,,,campaign_cost,163.934516,0.000000,163.934516,0.000000,CU,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,3,,,cost_per_removal,57.092962,57.092904,57.092962,57.759636,CU_per_removal,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,3,,,cost_per_removal_ratio_to_n1,0.364601,0.000000,0.364601,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-8 / Kosmos-3M second stage,amortization,3,,,removals,3.000000,0.000000,3.000000,0.000000,count,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,3,,,campaign_cost,171.278887,0.000000,171.278887,0.000000,CU,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,4,,,cost_per_removal,44.655793,44.655758,44.655793,59.533747,CU_per_removal,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,4,,,cost_per_removal_ratio_to_n1,0.285176,0.000000,0.285176,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-8 / Kosmos-3M second stage,amortization,4,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,4,,,campaign_cost,178.623146,0.000000,178.623146,0.000000,CU,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,5,,,cost_per_removal,45.986360,36.793479,45.986360,61.307881,CU_per_removal,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,5,,,cost_per_removal_ratio_to_n1,0.293673,0.000000,0.293673,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-8 / Kosmos-3M second stage,amortization,5,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,5,,,campaign_cost,183.945668,0.000000,183.945668,0.000000,CU,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,6,,,cost_per_removal,47.316960,37.857959,47.316960,63.082014,CU_per_removal,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,6,,,cost_per_removal_ratio_to_n1,0.302171,0.000000,0.302171,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-8 / Kosmos-3M second stage,amortization,6,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,6,,,campaign_cost,189.268068,0.000000,189.268068,0.000000,CU,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,7,,,cost_per_removal,48.647560,38.922439,48.647560,64.856147,CU_per_removal,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,7,,,cost_per_removal_ratio_to_n1,0.310668,0.000000,0.310668,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-8 / Kosmos-3M second stage,amortization,7,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,7,,,campaign_cost,194.590468,0.000000,194.590468,0.000000,CU,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,8,,,cost_per_removal,49.978160,39.986919,49.978160,66.630281,CU_per_removal,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,8,,,cost_per_removal_ratio_to_n1,0.319165,0.000000,0.319165,0.000000,ratio,"p50 relative to sweep min N",
+1.1,SL-8 / Kosmos-3M second stage,amortization,8,,,removals,4.000000,0.000000,4.000000,0.000000,count,"",
+1.1,SL-8 / Kosmos-3M second stage,amortization,8,,,campaign_cost,199.912868,0.000000,199.912868,0.000000,CU,"",
+1.1,SL-8 / Kosmos-3M second stage,fom,,,,removed_mass_per_mission,5600.000000,0.000000,5600.000000,0.000000,kg,"per mission at baseline N",
+1.1,SL-8 / Kosmos-3M second stage,fom,,,spatial_density,fom,31.350916,23.516074,31.350916,31.350940,kg_per_CU,"debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)",
+1.1,SL-8 / Kosmos-3M second stage,fom,,,spatial_density,band_weight,1.000000,1.000000,1.000000,1.000000,normalized,"w(h) PLACEHOLDER; cite on fill",
+1.1,SL-8 / Kosmos-3M second stage,fom,,,spatial_density,cost_per_risk_equiv_mass,0.031897,0.031897,0.031897,0.042524,CU_per_kg,"= C_campaign/sum(m_i*w(h_i)) = 1/fom per the same per-run FoM machinery (weights not recomputed); p50 is the reciprocal of the fom_p50 percentile BY CONSTRUCTION, NOT the percentile of per-run reciprocals (order statistics interpolate); p05/p95 are reciprocals of fom_p95/fom_p05 (order reverses for a decreasing transform)",
+1.1,SL-8 / Kosmos-3M second stage,fom,,,spatial_density,cost_per_risk_equiv_mass_musd,0.004931,0.004931,0.004931,0.006573,MUSD_per_kg,"= cost_per_risk_equiv_mass[CU_per_kg] x anchor_musd_per_cu[cost_scenario=mid]; mid-scenario anchor only to bound row count",mid
+1.1,SL-8 / Kosmos-3M second stage,fom,,,criticality,fom,21.318623,15.990930,21.318623,21.318639,kg_per_CU,"debris-risk-reduction per cost; weighting is PLACEHOLDER (T5)",
+1.1,SL-8 / Kosmos-3M second stage,fom,,,criticality,band_weight,0.680000,0.680000,0.680000,0.680000,normalized,"w(h) PLACEHOLDER; cite on fill",
+1.1,SL-8 / Kosmos-3M second stage,fom,,,criticality,cost_per_risk_equiv_mass,0.046907,0.046907,0.046907,0.062535,CU_per_kg,"= C_campaign/sum(m_i*w(h_i)) = 1/fom per the same per-run FoM machinery (weights not recomputed); p50 is the reciprocal of the fom_p50 percentile BY CONSTRUCTION, NOT the percentile of per-run reciprocals (order statistics interpolate); p05/p95 are reciprocals of fom_p95/fom_p05 (order reverses for a decreasing transform)",
+1.1,SL-8 / Kosmos-3M second stage,fom,,,criticality,cost_per_risk_equiv_mass_musd,0.007251,0.007251,0.007251,0.009667,MUSD_per_kg,"= cost_per_risk_equiv_mass[CU_per_kg] x anchor_musd_per_cu[cost_scenario=mid]; mid-scenario anchor only to bound row count",mid
+1.1,SL-8 / Kosmos-3M second stage,cost_component,4,C_dev,,component_cost,100.000000,0.000000,100.000000,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-8 / Kosmos-3M second stage,cost_component,4,C_bus,,component_cost,30.291410,0.000000,30.291410,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-8 / Kosmos-3M second stage,cost_component,4,C_kit_total,,component_cost,16.000000,0.000000,16.000000,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-8 / Kosmos-3M second stage,cost_component,4,C_launch,,component_cost,24.244000,0.000000,24.244000,0.000000,CU,"baseline breakdown at median mission time",
+1.1,SL-8 / Kosmos-3M second stage,cost_component,4,C_ops,,component_cost,8.087736,0.000000,8.087736,0.000000,CU,"baseline breakdown at median mission time",
+1.1,,cost_component_musd,,development,,cost,8.000000,0.000000,8.000000,0.000000,MUSD,"Smallsat bus + RPO/GNC dev NRE allocated to the campaign. Anchors: govt CubeSat/smallsat missions ~3-30 MUSD (New Space Economy survey); Aerospace Corp SSCM19 parametric CER methodology basis (aerospace.org/sscm); RPO-servicer ceiling analog Astroscale ELSA-d ~100 MUSD total two-spacecraft mission (184 kg servicer + 16 kg client). Low = lean COTS bus + heritage GNC; mid = smallsat band with RPO uplift; high = RPO-heavy half of the ELSA-d ceiling. [web-verified (analog) 2026-07-11]",low
+1.1,,cost_component_musd,,development,,cost,20.000000,0.000000,20.000000,0.000000,MUSD,"Smallsat bus + RPO/GNC dev NRE allocated to the campaign. Anchors: govt CubeSat/smallsat missions ~3-30 MUSD (New Space Economy survey); Aerospace Corp SSCM19 parametric CER methodology basis (aerospace.org/sscm); RPO-servicer ceiling analog Astroscale ELSA-d ~100 MUSD total two-spacecraft mission (184 kg servicer + 16 kg client). Low = lean COTS bus + heritage GNC; mid = smallsat band with RPO uplift; high = RPO-heavy half of the ELSA-d ceiling. [web-verified (analog) 2026-07-11]",mid
+1.1,,cost_component_musd,,development,,cost,50.000000,0.000000,50.000000,0.000000,MUSD,"Smallsat bus + RPO/GNC dev NRE allocated to the campaign. Anchors: govt CubeSat/smallsat missions ~3-30 MUSD (New Space Economy survey); Aerospace Corp SSCM19 parametric CER methodology basis (aerospace.org/sscm); RPO-servicer ceiling analog Astroscale ELSA-d ~100 MUSD total two-spacecraft mission (184 kg servicer + 16 kg client). Low = lean COTS bus + heritage GNC; mid = smallsat band with RPO uplift; high = RPO-heavy half of the ELSA-d ceiling. [web-verified (analog) 2026-07-11]",high
+1.1,,cost_component_musd,,manufacturing_bus_unit,,cost,1.500000,0.000000,1.500000,0.000000,MUSD,"One ~25 kg-dry RPO-capable recurring flight bus (NRE lives in the development row). Anchors: high-performance smallsat bus > 1 MUSD (New Space Economy survey); recurring-unit learning rates 95pct (units 1-10) / 90pct (11-50) / 85pct (>50) (ICEAA Learning Rate Sensitivity Model). [web-verified (analog) 2026-07-11]",low
+1.1,,cost_component_musd,,manufacturing_bus_unit,,cost,3.000000,0.000000,3.000000,0.000000,MUSD,"One ~25 kg-dry RPO-capable recurring flight bus (NRE lives in the development row). Anchors: high-performance smallsat bus > 1 MUSD (New Space Economy survey); recurring-unit learning rates 95pct (units 1-10) / 90pct (11-50) / 85pct (>50) (ICEAA Learning Rate Sensitivity Model). [web-verified (analog) 2026-07-11]",mid
+1.1,,cost_component_musd,,manufacturing_bus_unit,,cost,6.000000,0.000000,6.000000,0.000000,MUSD,"One ~25 kg-dry RPO-capable recurring flight bus (NRE lives in the development row). Anchors: high-performance smallsat bus > 1 MUSD (New Space Economy survey); recurring-unit learning rates 95pct (units 1-10) / 90pct (11-50) / 85pct (>50) (ICEAA Learning Rate Sensitivity Model). [web-verified (analog) 2026-07-11]",high
+1.1,,cost_component_musd,,launch_rideshare_sso,,cost,0.350000,0.000000,0.350000,0.000000,MUSD,"SpaceX rideshare SSO price card Feb 2026: base 350000 USD for up to 50 kg to SSO plus 7000 USD/kg above 50 kg; a ~44 kg servicer fits the 50 kg block. Mid/high add mid-inclination or dedicated-deployer/scheduling premiums (SpaceX directs non-SSO orbits to inquiry). [web-verified 2026-07-11]",low
+1.1,,cost_component_musd,,launch_rideshare_sso,,cost,0.500000,0.000000,0.500000,0.000000,MUSD,"SpaceX rideshare SSO price card Feb 2026: base 350000 USD for up to 50 kg to SSO plus 7000 USD/kg above 50 kg; a ~44 kg servicer fits the 50 kg block. Mid/high add mid-inclination or dedicated-deployer/scheduling premiums (SpaceX directs non-SSO orbits to inquiry). [web-verified 2026-07-11]",mid
+1.1,,cost_component_musd,,launch_rideshare_sso,,cost,0.700000,0.000000,0.700000,0.000000,MUSD,"SpaceX rideshare SSO price card Feb 2026: base 350000 USD for up to 50 kg to SSO plus 7000 USD/kg above 50 kg; a ~44 kg servicer fits the 50 kg block. Mid/high add mid-inclination or dedicated-deployer/scheduling premiums (SpaceX directs non-SSO orbits to inquiry). [web-verified 2026-07-11]",high
+1.1,,cost_component_musd,,launch_dedicated_anchor,,cost,7.100000,0.000000,7.100000,0.000000,MUSD,"Context row NOT additive with launch_rideshare_sso -- alternative anchor if a dedicated orbit/schedule is required instead of rideshare; feeds the launch-HIGH bound (7.5 MUSD) of the anchor core's C_launch term (Sec.10 Method / adjudicated B3 -- width preserved, not narrowed). Rocket Lab Electron: ~300 kg to LEO dedicated mission; Q1-2025 realized avg revenue/launch 7.1 MUSD (SEC 10-Q); ~7.5 MUSD nominal dedicated-mission price (SpaceNexus explainer). [web-verified 2026-07-11]",low
+1.1,,cost_component_musd,,launch_dedicated_anchor,,cost,7.500000,0.000000,7.500000,0.000000,MUSD,"Context row NOT additive with launch_rideshare_sso -- alternative anchor if a dedicated orbit/schedule is required instead of rideshare; feeds the launch-HIGH bound (7.5 MUSD) of the anchor core's C_launch term (Sec.10 Method / adjudicated B3 -- width preserved, not narrowed). Rocket Lab Electron: ~300 kg to LEO dedicated mission; Q1-2025 realized avg revenue/launch 7.1 MUSD (SEC 10-Q); ~7.5 MUSD nominal dedicated-mission price (SpaceNexus explainer). [web-verified 2026-07-11]",mid
+1.1,,cost_component_musd,,launch_dedicated_anchor,,cost,7.500000,0.000000,7.500000,0.000000,MUSD,"Context row NOT additive with launch_rideshare_sso -- alternative anchor if a dedicated orbit/schedule is required instead of rideshare; feeds the launch-HIGH bound (7.5 MUSD) of the anchor core's C_launch term (Sec.10 Method / adjudicated B3 -- width preserved, not narrowed). Rocket Lab Electron: ~300 kg to LEO dedicated mission; Q1-2025 realized avg revenue/launch 7.1 MUSD (SEC 10-Q); ~7.5 MUSD nominal dedicated-mission price (SpaceNexus explainer). [web-verified 2026-07-11]",high
+1.1,,cost_component_musd,,ground_segment_3yr,,cost,0.060000,0.000000,0.060000,0.000000,MUSD,"Derived: AWS Ground Station published per-minute rates 3 USD/min (narrowband reserved) to 22 USD/min (wideband on-demand) at 4-6 passes/day imply ~20000-60000 USD/yr all-in ground segment (AWS billing docs + ElasticScale / New Space Economy secondary confirmation); x3 yr campaign span gives 0.06-0.18 MUSD. [web-verified 2026-07-11]",low
+1.1,,cost_component_musd,,ground_segment_3yr,,cost,0.120000,0.000000,0.120000,0.000000,MUSD,"Derived: AWS Ground Station published per-minute rates 3 USD/min (narrowband reserved) to 22 USD/min (wideband on-demand) at 4-6 passes/day imply ~20000-60000 USD/yr all-in ground segment (AWS billing docs + ElasticScale / New Space Economy secondary confirmation); x3 yr campaign span gives 0.06-0.18 MUSD. [web-verified 2026-07-11]",mid
+1.1,,cost_component_musd,,ground_segment_3yr,,cost,0.180000,0.000000,0.180000,0.000000,MUSD,"Derived: AWS Ground Station published per-minute rates 3 USD/min (narrowband reserved) to 22 USD/min (wideband on-demand) at 4-6 passes/day imply ~20000-60000 USD/yr all-in ground segment (AWS billing docs + ElasticScale / New Space Economy secondary confirmation); x3 yr campaign span gives 0.06-0.18 MUSD. [web-verified 2026-07-11]",high
+1.1,,cost_component_musd,,operations_3yr,,cost,1.000000,0.000000,1.000000,0.000000,MUSD,"3-yr campaign routine ops: 1-2 FTE at 150000-200000 USD/yr fully-burdened plus mission-ops software 50000-200000 USD/yr (SpaceNexus mission-cost guide); ops roughly 8pct of total mission cost / 20pct of bus cost. C_ops (CU) tracks ~4 sim-days not the real multi-year ops span -- this row is the real-dollar multi-year figure. [web-verified (analog) 2026-07-11]",low
+1.1,,cost_component_musd,,operations_3yr,,cost,2.000000,0.000000,2.000000,0.000000,MUSD,"3-yr campaign routine ops: 1-2 FTE at 150000-200000 USD/yr fully-burdened plus mission-ops software 50000-200000 USD/yr (SpaceNexus mission-cost guide); ops roughly 8pct of total mission cost / 20pct of bus cost. C_ops (CU) tracks ~4 sim-days not the real multi-year ops span -- this row is the real-dollar multi-year figure. [web-verified (analog) 2026-07-11]",mid
+1.1,,cost_component_musd,,operations_3yr,,cost,4.000000,0.000000,4.000000,0.000000,MUSD,"3-yr campaign routine ops: 1-2 FTE at 150000-200000 USD/yr fully-burdened plus mission-ops software 50000-200000 USD/yr (SpaceNexus mission-cost guide); ops roughly 8pct of total mission cost / 20pct of bus cost. C_ops (CU) tracks ~4 sim-days not the real multi-year ops span -- this row is the real-dollar multi-year figure. [web-verified (analog) 2026-07-11]",high
+1.1,,cost_component_musd,,ssa_tracking,,cost,0.000000,0.000000,0.000000,0.000000,MUSD/yr,"LeoLabs declines to publicly quote; subscription is customized by fleet size and monitoring cadence (SpaceNews; CNBC). Zero-cost floor pointer: USSF/18th SDS free public conjunction data messages (CDMs). 0/0/0 is an UNFILLED placeholder not a claim the service is free -- do not invent a subscription figure. [PLACEHOLDER]",low
+1.1,,cost_component_musd,,ssa_tracking,,cost,0.000000,0.000000,0.000000,0.000000,MUSD/yr,"LeoLabs declines to publicly quote; subscription is customized by fleet size and monitoring cadence (SpaceNews; CNBC). Zero-cost floor pointer: USSF/18th SDS free public conjunction data messages (CDMs). 0/0/0 is an UNFILLED placeholder not a claim the service is free -- do not invent a subscription figure. [PLACEHOLDER]",mid
+1.1,,cost_component_musd,,ssa_tracking,,cost,0.000000,0.000000,0.000000,0.000000,MUSD/yr,"LeoLabs declines to publicly quote; subscription is customized by fleet size and monitoring cadence (SpaceNews; CNBC). Zero-cost floor pointer: USSF/18th SDS free public conjunction data messages (CDMs). 0/0/0 is an UNFILLED placeholder not a claim the service is free -- do not invent a subscription figure. [PLACEHOLDER]",high
+1.1,,cost_component_musd,,insurance_launch_plus_1yr_pct,,cost,5.000000,0.000000,5.000000,0.000000,% insured value,"Launch-plus-first-year-in-orbit premium as pct of insured value; range rises after major-loss years (2023-24 hardened the market) or for unproven vehicles (Orbital Radar; Taylor Wessing; SpaceNews). Many smallsat missions self-insure -- applied only in the campaign_cost_musd_with_reserves row never in the base cost. [web-verified 2026-07-11]",low
+1.1,,cost_component_musd,,insurance_launch_plus_1yr_pct,,cost,8.000000,0.000000,8.000000,0.000000,% insured value,"Launch-plus-first-year-in-orbit premium as pct of insured value; range rises after major-loss years (2023-24 hardened the market) or for unproven vehicles (Orbital Radar; Taylor Wessing; SpaceNews). Many smallsat missions self-insure -- applied only in the campaign_cost_musd_with_reserves row never in the base cost. [web-verified 2026-07-11]",mid
+1.1,,cost_component_musd,,insurance_launch_plus_1yr_pct,,cost,12.000000,0.000000,12.000000,0.000000,% insured value,"Launch-plus-first-year-in-orbit premium as pct of insured value; range rises after major-loss years (2023-24 hardened the market) or for unproven vehicles (Orbital Radar; Taylor Wessing; SpaceNews). Many smallsat missions self-insure -- applied only in the campaign_cost_musd_with_reserves row never in the base cost. [web-verified 2026-07-11]",high
+1.1,,cost_component_musd,,licensing_fcc_application,,cost,0.030000,0.000000,0.030000,0.000000,MUSD,"FCC Part 25 application fee one-time (not annual): streamlined smallsat 30000 USD (6-yr license term) is low; standard 45000 USD (15-yr term) is high; mid is the arithmetic midpoint (no published intermediate tier). [web-verified 2026-07-11]",low
+1.1,,cost_component_musd,,licensing_fcc_application,,cost,0.037500,0.000000,0.037500,0.000000,MUSD,"FCC Part 25 application fee one-time (not annual): streamlined smallsat 30000 USD (6-yr license term) is low; standard 45000 USD (15-yr term) is high; mid is the arithmetic midpoint (no published intermediate tier). [web-verified 2026-07-11]",mid
+1.1,,cost_component_musd,,licensing_fcc_application,,cost,0.045000,0.000000,0.045000,0.000000,MUSD,"FCC Part 25 application fee one-time (not annual): streamlined smallsat 30000 USD (6-yr license term) is low; standard 45000 USD (15-yr term) is high; mid is the arithmetic midpoint (no published intermediate tier). [web-verified 2026-07-11]",high
+1.1,,cost_component_musd,,licensing_fcc_annual,,cost,0.012215,0.000000,0.012215,0.000000,MUSD/yr,"FCC annual regulatory fee NGSO Small Satellite category FY2024: 12215 USD per license/call sign (Federal Register FY2024 fee assessment; FCC 25-31); category explicitly includes On-Orbit Servicing (OOS) and Rendezvous and Proximity Operations (RPO) -- the ADSC servicer class. Single published figure flat across low/mid/high. [web-verified 2026-07-11]",low
+1.1,,cost_component_musd,,licensing_fcc_annual,,cost,0.012215,0.000000,0.012215,0.000000,MUSD/yr,"FCC annual regulatory fee NGSO Small Satellite category FY2024: 12215 USD per license/call sign (Federal Register FY2024 fee assessment; FCC 25-31); category explicitly includes On-Orbit Servicing (OOS) and Rendezvous and Proximity Operations (RPO) -- the ADSC servicer class. Single published figure flat across low/mid/high. [web-verified 2026-07-11]",mid
+1.1,,cost_component_musd,,licensing_fcc_annual,,cost,0.012215,0.000000,0.012215,0.000000,MUSD/yr,"FCC annual regulatory fee NGSO Small Satellite category FY2024: 12215 USD per license/call sign (Federal Register FY2024 fee assessment; FCC 25-31); category explicitly includes On-Orbit Servicing (OOS) and Rendezvous and Proximity Operations (RPO) -- the ADSC servicer class. Single published figure flat across low/mid/high. [web-verified 2026-07-11]",high
+1.1,,cost_component_musd,,kit_unit_scaled_9t,,cost,0.100000,0.000000,0.100000,0.000000,MUSD/unit,"9-t-class installer deorbit kit; no citable product exists at this scale. Extrapolated from Terminator Tape (50000-75000 USD/unit CubeSat-scale lower-bound analog flight-proven) and dragNET (2.8 kg module mass/design cited no public unit price) up to a custom EDT/sail plus attach interface for a multi-tonne derelict; upper bound is engineering judgment. [training-data extrapolation]",low
+1.1,,cost_component_musd,,kit_unit_scaled_9t,,cost,0.500000,0.000000,0.500000,0.000000,MUSD/unit,"9-t-class installer deorbit kit; no citable product exists at this scale. Extrapolated from Terminator Tape (50000-75000 USD/unit CubeSat-scale lower-bound analog flight-proven) and dragNET (2.8 kg module mass/design cited no public unit price) up to a custom EDT/sail plus attach interface for a multi-tonne derelict; upper bound is engineering judgment. [training-data extrapolation]",mid
+1.1,,cost_component_musd,,kit_unit_scaled_9t,,cost,2.000000,0.000000,2.000000,0.000000,MUSD/unit,"9-t-class installer deorbit kit; no citable product exists at this scale. Extrapolated from Terminator Tape (50000-75000 USD/unit CubeSat-scale lower-bound analog flight-proven) and dragNET (2.8 kg module mass/design cited no public unit price) up to a custom EDT/sail plus attach interface for a multi-tonne derelict; upper bound is engineering judgment. [training-data extrapolation]",high
+1.1,,cost_component_musd,,contingency_reserve_pct,,cost,20.000000,0.000000,20.000000,0.000000,% of cost,"Standard NASA development-phase cost-reserve guideline for Class C/D smallsat missions (NASA Cost Estimating Handbook v4.0; NASA reserves lesson 1780; NASA TMC Class C/D expectations); applied to the pre-reserve campaign cost in the campaign_cost_musd_with_reserves row. [web-verified 2026-07-11]",low
+1.1,,cost_component_musd,,contingency_reserve_pct,,cost,25.000000,0.000000,25.000000,0.000000,% of cost,"Standard NASA development-phase cost-reserve guideline for Class C/D smallsat missions (NASA Cost Estimating Handbook v4.0; NASA reserves lesson 1780; NASA TMC Class C/D expectations); applied to the pre-reserve campaign cost in the campaign_cost_musd_with_reserves row. [web-verified 2026-07-11]",mid
+1.1,,cost_component_musd,,contingency_reserve_pct,,cost,30.000000,0.000000,30.000000,0.000000,% of cost,"Standard NASA development-phase cost-reserve guideline for Class C/D smallsat missions (NASA Cost Estimating Handbook v4.0; NASA reserves lesson 1780; NASA TMC Class C/D expectations); applied to the pre-reserve campaign cost in the campaign_cost_musd_with_reserves row. [web-verified 2026-07-11]",high
+1.1,,currency_anchor_derived,4,,,anchor_musd_per_cu,0.062781,0.000000,0.062781,0.000000,musd_per_cu,"anchor_musd_per_cu[low] = core_5_component / C_campaign_CU_p50; core = development(8.0000) + manufacturing_bus_unit(1.5000) + kit_unit_scaled_9t*4kits(0.4000) + C_launch_core(0.3500) + operations_3yr(1.0000) = 11.250000 MUSD; C_campaign_CU_p50(catalog=SL-16 / Zenit-2 second stage;baseline N=4)=179.195140 CU; anchor=0.062781 MUSD/CU. C_launch_core[low]=0.3500 MUSD (low=rideshare card low, mid=rideshare upper/heavier buy, high=dedicated Electron -- launch_dedicated_anchor feeds this high bound, width preserved not narrowed, B3). ONLY these 5 CU-mapped components are in the anchor core (Sec.10 Method, B2): ground_segment_3yr, licensing_fcc_application, licensing_fcc_annual*3yr and ssa_tracking (PLACEHOLDER) are ADDITIVE, outside the core (see campaign_cost_musd additives/base_total rows); insurance_launch_plus_1yr_pct and contingency_reserve_pct apply only in campaign_cost_musd[with_reserves]",low
+1.1,,currency_anchor_derived,4,,,anchor_musd_per_cu,0.154580,0.000000,0.154580,0.000000,musd_per_cu,"anchor_musd_per_cu[mid] = core_5_component / C_campaign_CU_p50; core = development(20.0000) + manufacturing_bus_unit(3.0000) + kit_unit_scaled_9t*4kits(2.0000) + C_launch_core(0.7000) + operations_3yr(2.0000) = 27.700000 MUSD; C_campaign_CU_p50(catalog=SL-16 / Zenit-2 second stage;baseline N=4)=179.195140 CU; anchor=0.154580 MUSD/CU. C_launch_core[mid]=0.7000 MUSD (low=rideshare card low, mid=rideshare upper/heavier buy, high=dedicated Electron -- launch_dedicated_anchor feeds this high bound, width preserved not narrowed, B3). ONLY these 5 CU-mapped components are in the anchor core (Sec.10 Method, B2): ground_segment_3yr, licensing_fcc_application, licensing_fcc_annual*3yr and ssa_tracking (PLACEHOLDER) are ADDITIVE, outside the core (see campaign_cost_musd additives/base_total rows); insurance_launch_plus_1yr_pct and contingency_reserve_pct apply only in campaign_cost_musd[with_reserves]",mid
+1.1,,currency_anchor_derived,4,,,anchor_musd_per_cu,0.421328,0.000000,0.421328,0.000000,musd_per_cu,"anchor_musd_per_cu[high] = core_5_component / C_campaign_CU_p50; core = development(50.0000) + manufacturing_bus_unit(6.0000) + kit_unit_scaled_9t*4kits(8.0000) + C_launch_core(7.5000) + operations_3yr(4.0000) = 75.500000 MUSD; C_campaign_CU_p50(catalog=SL-16 / Zenit-2 second stage;baseline N=4)=179.195140 CU; anchor=0.421328 MUSD/CU. C_launch_core[high]=7.5000 MUSD (low=rideshare card low, mid=rideshare upper/heavier buy, high=dedicated Electron -- launch_dedicated_anchor feeds this high bound, width preserved not narrowed, B3). ONLY these 5 CU-mapped components are in the anchor core (Sec.10 Method, B2): ground_segment_3yr, licensing_fcc_application, licensing_fcc_annual*3yr and ssa_tracking (PLACEHOLDER) are ADDITIVE, outside the core (see campaign_cost_musd additives/base_total rows); insurance_launch_plus_1yr_pct and contingency_reserve_pct apply only in campaign_cost_musd[with_reserves]",high
+1.1,,campaign_cost_musd,4,,,core_5_component,11.250000,0.000000,11.250000,0.000000,MUSD,"campaign_cost_musd[core_5_component;low] = the 5 CU-mapped components (development+manufacturing_bus_unit+kit_unit_scaled_9t*4kits+C_launch_core+operations_3yr); identical numerator to currency_anchor_derived = 11.250000 MUSD",low
+1.1,,campaign_cost_musd,4,,,additives,0.126645,0.000000,0.126645,0.000000,MUSD,"campaign_cost_musd[additives;low] = ground_segment_3yr + licensing_fcc_application + licensing_fcc_annual*3yr + ssa_tracking (PLACEHOLDER, 0); outside the CU model and outside the anchor core (Sec.10 Method) = 0.126645 MUSD",low
+1.1,,campaign_cost_musd,4,,,base_total,11.376645,0.000000,11.376645,0.000000,MUSD,"campaign_cost_musd[base_total;low] = core_5_component(11.250000) + additives(0.126645) = 11.376645 MUSD",low
+1.1,,campaign_cost_musd,4,,,with_reserves,13.764474,0.000000,13.764474,0.000000,MUSD,"campaign_cost_musd[with_reserves;low] = base_total(11.376645 MUSD) * (1+contingency_reserve_pct[low]/100=1.2000) + insurance_line(0.112500 MUSD); insurance_line = insurance_launch_plus_1yr_pct[low]/100=0.0500 * insured_value(manufacturing_bus_unit+kit_unit_scaled_9t*4kits+C_launch_core=2.250000 MUSD) = 0.112500 MUSD -- insurance scales with asset+launch value (N5), NOT multiplied over the multi-year opex in base_total",low
+1.1,,campaign_cost_musd,4,,,core_5_component,27.700000,0.000000,27.700000,0.000000,MUSD,"campaign_cost_musd[core_5_component;mid] = the 5 CU-mapped components (development+manufacturing_bus_unit+kit_unit_scaled_9t*4kits+C_launch_core+operations_3yr); identical numerator to currency_anchor_derived = 27.700000 MUSD",mid
+1.1,,campaign_cost_musd,4,,,additives,0.194145,0.000000,0.194145,0.000000,MUSD,"campaign_cost_musd[additives;mid] = ground_segment_3yr + licensing_fcc_application + licensing_fcc_annual*3yr + ssa_tracking (PLACEHOLDER, 0); outside the CU model and outside the anchor core (Sec.10 Method) = 0.194145 MUSD",mid
+1.1,,campaign_cost_musd,4,,,base_total,27.894145,0.000000,27.894145,0.000000,MUSD,"campaign_cost_musd[base_total;mid] = core_5_component(27.700000) + additives(0.194145) = 27.894145 MUSD",mid
+1.1,,campaign_cost_musd,4,,,with_reserves,35.323681,0.000000,35.323681,0.000000,MUSD,"campaign_cost_musd[with_reserves;mid] = base_total(27.894145 MUSD) * (1+contingency_reserve_pct[mid]/100=1.2500) + insurance_line(0.456000 MUSD); insurance_line = insurance_launch_plus_1yr_pct[mid]/100=0.0800 * insured_value(manufacturing_bus_unit+kit_unit_scaled_9t*4kits+C_launch_core=5.700000 MUSD) = 0.456000 MUSD -- insurance scales with asset+launch value (N5), NOT multiplied over the multi-year opex in base_total",mid
+1.1,,campaign_cost_musd,4,,,core_5_component,75.500000,0.000000,75.500000,0.000000,MUSD,"campaign_cost_musd[core_5_component;high] = the 5 CU-mapped components (development+manufacturing_bus_unit+kit_unit_scaled_9t*4kits+C_launch_core+operations_3yr); identical numerator to currency_anchor_derived = 75.500000 MUSD",high
+1.1,,campaign_cost_musd,4,,,additives,0.261645,0.000000,0.261645,0.000000,MUSD,"campaign_cost_musd[additives;high] = ground_segment_3yr + licensing_fcc_application + licensing_fcc_annual*3yr + ssa_tracking (PLACEHOLDER, 0); outside the CU model and outside the anchor core (Sec.10 Method) = 0.261645 MUSD",high
+1.1,,campaign_cost_musd,4,,,base_total,75.761645,0.000000,75.761645,0.000000,MUSD,"campaign_cost_musd[base_total;high] = core_5_component(75.500000) + additives(0.261645) = 75.761645 MUSD",high
+1.1,,campaign_cost_musd,4,,,with_reserves,101.070138,0.000000,101.070138,0.000000,MUSD,"campaign_cost_musd[with_reserves;high] = base_total(75.761645 MUSD) * (1+contingency_reserve_pct[high]/100=1.3000) + insurance_line(2.580000 MUSD); insurance_line = insurance_launch_plus_1yr_pct[high]/100=0.1200 * insured_value(manufacturing_bus_unit+kit_unit_scaled_9t*4kits+C_launch_core=21.500000 MUSD) = 2.580000 MUSD -- insurance scales with asset+launch value (N5), NOT multiplied over the multi-year opex in base_total",high
+1.1,SL-16 / Zenit-2 second stage,cost_per_removal_musd,4,,,cost_per_removal,2.812500,2.812498,2.812500,3.749542,MUSD_per_removal,"cost_per_removal_musd[low] = cost_per_removal_CU(catalog=SL-16 / Zenit-2 second stage;baseline N=4; p05=44.7988 p50=44.7988 p95=59.7244) x anchor_musd_per_cu[low]=0.062781 MUSD/CU",low
+1.1,SL-16 / Zenit-2 second stage,cost_per_removal_musd,4,,,cost_per_removal,6.925001,6.924995,6.925001,9.232205,MUSD_per_removal,"cost_per_removal_musd[mid] = cost_per_removal_CU(catalog=SL-16 / Zenit-2 second stage;baseline N=4; p05=44.7988 p50=44.7988 p95=59.7244) x anchor_musd_per_cu[mid]=0.154580 MUSD/CU",mid
+1.1,SL-16 / Zenit-2 second stage,cost_per_removal_musd,4,,,cost_per_removal,18.875003,18.874988,18.875003,25.163591,MUSD_per_removal,"cost_per_removal_musd[high] = cost_per_removal_CU(catalog=SL-16 / Zenit-2 second stage;baseline N=4; p05=44.7988 p50=44.7988 p95=59.7244) x anchor_musd_per_cu[high]=0.421328 MUSD/CU",high
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_dev_cu,,cost_per_removal_swing,15.000000,0.000000,15.000000,0.000000,CU_per_removal,"low=37.2988;high=52.2988 (+/-30% one-at-a-time)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_dev_cu,,fom_swing,53.981127,0.000000,53.981127,0.000000,kg_per_CU,"low=188.20985;high=134.22872 (spatial FoM)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_bus_cu,,cost_per_removal_swing,4.543711,0.000000,4.543711,0.000000,CU_per_removal,"low=42.5269;high=47.0706 (+/-30% one-at-a-time)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_bus_cu,,fom_swing,15.934322,0.000000,15.934322,0.000000,kg_per_CU,"low=165.07185;high=149.13753 (spatial FoM)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_launch_cu_per_kg,,cost_per_removal_swing,3.722400,0.000000,3.722400,0.000000,CU_per_removal,"low=42.9376;high=46.6600 (+/-30% one-at-a-time)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_launch_cu_per_kg,,fom_swing,13.043009,0.000000,13.043009,0.000000,kg_per_CU,"low=163.49310;high=150.45009 (spatial FoM)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_kit_cu,,cost_per_removal_swing,2.400000,0.000000,2.400000,0.000000,CU_per_removal,"low=43.5988;high=45.9988 (+/-30% one-at-a-time)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_kit_cu,,fom_swing,8.400932,0.000000,8.400932,0.000000,kg_per_CU,"low=161.01364;high=152.61270 (spatial FoM)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_ops_cu_per_day,,cost_per_removal_swing,1.213164,0.000000,1.213164,0.000000,CU_per_removal,"low=44.1922;high=45.4054 (+/-30% one-at-a-time)",
+1.1,SL-16 / Zenit-2 second stage,tornado,,c_ops_cu_per_day,,fom_swing,4.244275,0.000000,4.244275,0.000000,kg_per_CU,"low=158.85153;high=154.60725 (spatial FoM)",
+1.1,,currency_anchor,,,,cu_to_musd_range,0.000000,0.000000,0.000000,0.000000,musd_per_cu,"SUPERSEDED sentinel (CostConfig::cu_to_musd_low/high stay 0.0 for schema continuity); the anchor is now DERIVED AT RUNTIME from the WP14 itemized cost table (see currency_anchor_derived rows) -- no point-value dollar figure is claimed",
diff --git a/generated/wp6_cost_summary.md b/generated/wp6_cost_summary.md
index 89af5d4..449d5c4 100644
--- a/generated/wp6_cost_summary.md
+++ b/generated/wp6_cost_summary.md
@@ -1,6 +1,6 @@
# WP6 Parametric cost model + FoM — summary
-Primary output is in **relative cost units (CU)**; **no point-value dollar figure is claimed** (R6/D10) -- the CU->currency anchor is a PLACEHOLDER cited range, filled in WP7. All cost/FoM parameters are PLACEHOLDER (see `CostConfig`). Cost, cost/removal and FoM are propagated **per run** from the WP5 campaign (schema 1.0) and reported as p05/p50/p95. Regenerate with `adsc_cost`.
+Primary output is in **relative cost units (CU)**; **no point-value dollar figure is claimed** (R6/D10) -- the CU->currency anchor is now DERIVED AT RUNTIME from the WP14 cited itemized cost table (see the `currency_anchor_derived` rows in the CSV), per cost_scenario (low/mid/high); `CostConfig::cu_to_musd_low/high` are superseded sentinels left at 0.0. All cost/FoM parameters are PLACEHOLDER (see `CostConfig`). Cost, cost/removal and FoM are propagated **per run** from the WP5 campaign (schema 1.0) and reported as p05/p50/p95. Regenerate with `adsc_cost`.
## SL-16 / Zenit-2 second stage
diff --git a/include/adsc/cost.hpp b/include/adsc/cost.hpp
index 3c8a59d..0182d04 100644
--- a/include/adsc/cost.hpp
+++ b/include/adsc/cost.hpp
@@ -22,23 +22,28 @@ namespace adsc {
// C_campaign = C_dev + C_bus(m_dry) + N*C_kit + C_launch(m_wet, band)
// + C_ops(T)
// The PRIMARY output is in RELATIVE cost units (CU). Absolute currency is NEVER
-// emitted as a point value (R6/D10): only a cited RANGE via the PLACEHOLDER
-// CU->currency anchor, left unfilled here (filled with citations in WP7).
+// emitted as a point value (R6/D10): only cited low/mid/high RANGES. WP14
+// adds a cited itemized USD table (`wp14_cost_items()`) and derives the
+// CU->currency anchor from it AT RUNTIME (see write_cost_summary_csv in
+// cost.cpp) -- `CostConfig::cu_to_musd_low/high` stay unused sentinels.
//
// Figure of merit (spec §4): FoM = sum_i m_i*w(h_i) / C_campaign, a
// debris-risk-reduction-per-cost (cascade-fuel) metric. Reported under >=2
// normalized congestion weightings; the ranking disagreement is open trade T5.
+// WP14 also reports the inverse orientation, cost_per_risk_equiv_mass =
+// C_campaign / sum_i m_i*w(h_i), as CU/kg and (mid cost_scenario) MUSD/kg.
//
// Distribution propagation: cost, cost/removal and FoM are computed PER RUN
// from the WP5 per-run removals/mission-time and reported as p05/p50/p95 --
// never mean-only.
//
-// All parameters live in CostConfig and are marked PLACEHOLDER (R10). WP6
-// implements the cost model / amortization / tornado / FoM only -- no WP7
-// evidence pack, no charts.
+// The relative CU coefficients and both FoM weight tables in CostConfig are
+// PLACEHOLDER (R10) and untouched by WP14. WP6/WP14 implement the cost model
+// / amortization / tornado / FoM / itemized-USD-anchor only -- no evidence
+// pack, no charts (those are separate tools that consume this CSV).
// ============================================================================
-inline const char* wp6_schema_version() { return "1.0"; }
+inline const char* wp6_schema_version() { return "1.1"; }
// A congestion-weight anchor: normalized (peak ~1) weight of a target band at
// `altitude_km` under each weighting. PLACEHOLDER values -- fill with citations
@@ -56,6 +61,29 @@ struct WeightAnchor {
enum class Weighting { SpatialDensity, Criticality };
const char* weighting_label(Weighting w);
+// WP14 itemized absolute-cost row (low/mid/high, cited or PLACEHOLDER). Source:
+// `_tasks_local/wp14-cost-sources.md` (retrieval date 2026-07-11); every row's
+// `note` ends with one of "[web-verified 2026-07-11]" / "[web-verified
+// (analog) 2026-07-11]" / "[training-data extrapolation]" / "[PLACEHOLDER]"
+// (D10: sourced-or-PLACEHOLDER, never fabricated). This table is separate
+// from, and does not alter, the relative CU `CostConfig` coefficients above
+// -- it is consumed only to derive the currency-anchor rows written into
+// wp6_cost_summary.csv (schema 1.1, WP14).
+struct CostItemUsd {
+ const char* item; // stable machine-readable row id (snake_case)
+ double low_musd; // low estimate, in `unit`
+ double mid_musd; // mid estimate, in `unit`
+ double high_musd; // high estimate, in `unit`
+ const char* unit; // e.g. "MUSD", "MUSD/yr", "MUSD/unit", "% of cost"
+ const char* note; // ASCII provenance note, ends with a bracketed flag
+};
+
+// The static WP14 itemized cost table (development, manufacturing, launch,
+// ground, ops, SSA, insurance/licensing, kit unit, contingency -- see
+// cost.cpp). `*count` is set to the row count; the returned pointer is valid
+// for the process lifetime (static storage).
+const CostItemUsd* wp14_cost_items(int* count);
+
// All PLACEHOLDER; grouped so nothing is a bare literal in the cost logic (R10).
struct CostConfig {
// --- relative cost-unit (CU) coefficients ---
@@ -79,11 +107,17 @@ struct CostConfig {
// Tornado sensitivity half-width (fractional one-at-a-time perturbation).
double tornado_delta_frac = 0.30; // PLACEHOLDER +/-30%
- // CU -> currency anchor RANGE. Left 0 => NOT filled; no dollar value is
- // emitted. Fill with a CITED range (public launch $/kg + smallsat bus cost
- // ranges) in WP7; a point-value dollar claim is forbidden (R6/D10).
- double cu_to_musd_low = 0.0; // PLACEHOLDER (cited range, WP7)
- double cu_to_musd_high = 0.0; // PLACEHOLDER (cited range, WP7)
+ // CU -> currency anchor RANGE. SUPERSEDED by WP14: the anchor is now
+ // DERIVED AT RUNTIME (see `wp14_cost_items()` + write_cost_summary_csv in
+ // cost.cpp) from the cited itemized USD table, per cost_scenario
+ // (low/mid/high), and written into the CSV as `currency_anchor_derived`
+ // rows -- it is no longer read from these two fields. These fields stay
+ // intentionally unused sentinels at 0.0 (not repurposed, not removed) so
+ // the pre-WP14 "unfilled anchor" config shape is preserved for any caller
+ // still reading `CostConfig` directly; a point-value dollar claim remains
+ // forbidden everywhere (R6/D10) -- only cited low/mid/high ranges appear.
+ double cu_to_musd_low = 0.0; // unused sentinel; anchor is WP14 runtime-derived
+ double cu_to_musd_high = 0.0; // unused sentinel; anchor is WP14 runtime-derived
// Normalized congestion-weight table (PLACEHOLDER; cite on fill). Peaks
// differ by column so band ranking flips between weightings (T5).
diff --git a/src/cost.cpp b/src/cost.cpp
index 459d201..119b892 100644
--- a/src/cost.cpp
+++ b/src/cost.cpp
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -11,6 +12,115 @@
namespace adsc {
+// ============================================================================
+// WP14 itemized absolute-cost table -- transcribed EXACTLY from
+// `_tasks_local/wp14-cost-sources.md` (retrieval date 2026-07-11). Every note
+// ends with its verified-status flag; D10 (sourced-or-PLACEHOLDER, never
+// fabricated). Units vary by row: MUSD, MUSD/yr, MUSD/unit, or a percentage
+// applied elsewhere (never as a bare point-dollar figure -- R6/D10).
+// ============================================================================
+namespace {
+
+const CostItemUsd kWp14CostItems[] = {
+ {"development", 8.0, 20.0, 50.0, "MUSD",
+ "Smallsat bus + RPO/GNC dev NRE allocated to the campaign. Anchors: govt "
+ "CubeSat/smallsat missions ~3-30 MUSD (New Space Economy survey); "
+ "Aerospace Corp SSCM19 parametric CER methodology basis "
+ "(aerospace.org/sscm); RPO-servicer ceiling analog Astroscale ELSA-d "
+ "~100 MUSD total two-spacecraft mission (184 kg servicer + 16 kg "
+ "client). Low = lean COTS bus + heritage GNC; mid = smallsat band with "
+ "RPO uplift; high = RPO-heavy half of the ELSA-d ceiling. "
+ "[web-verified (analog) 2026-07-11]"},
+ {"manufacturing_bus_unit", 1.5, 3.0, 6.0, "MUSD",
+ "One ~25 kg-dry RPO-capable recurring flight bus (NRE lives in the "
+ "development row). Anchors: high-performance smallsat bus > 1 MUSD (New "
+ "Space Economy survey); recurring-unit learning rates 95pct (units "
+ "1-10) / 90pct (11-50) / 85pct (>50) (ICEAA Learning Rate Sensitivity "
+ "Model). [web-verified (analog) 2026-07-11]"},
+ {"launch_rideshare_sso", 0.35, 0.5, 0.7, "MUSD",
+ "SpaceX rideshare SSO price card Feb 2026: base 350000 USD for up to "
+ "50 kg to SSO plus 7000 USD/kg above 50 kg; a ~44 kg servicer fits the "
+ "50 kg block. Mid/high add mid-inclination or dedicated-deployer/"
+ "scheduling premiums (SpaceX directs non-SSO orbits to inquiry). "
+ "[web-verified 2026-07-11]"},
+ {"launch_dedicated_anchor", 7.1, 7.5, 7.5, "MUSD",
+ "Context row NOT additive with launch_rideshare_sso -- alternative "
+ "anchor if a dedicated orbit/schedule is required instead of "
+ "rideshare; feeds the launch-HIGH bound (7.5 MUSD) of the anchor "
+ "core's C_launch term (Sec.10 Method / adjudicated B3 -- width "
+ "preserved, not narrowed). Rocket Lab Electron: ~300 kg to LEO "
+ "dedicated mission; Q1-2025 realized avg revenue/launch 7.1 MUSD (SEC "
+ "10-Q); ~7.5 MUSD nominal dedicated-mission price (SpaceNexus "
+ "explainer). [web-verified 2026-07-11]"},
+ {"ground_segment_3yr", 0.06, 0.12, 0.18, "MUSD",
+ "Derived: AWS Ground Station published per-minute rates 3 USD/min "
+ "(narrowband reserved) to 22 USD/min (wideband on-demand) at 4-6 "
+ "passes/day imply ~20000-60000 USD/yr all-in ground segment (AWS "
+ "billing docs + ElasticScale / New Space Economy secondary "
+ "confirmation); x3 yr campaign span gives 0.06-0.18 MUSD. "
+ "[web-verified 2026-07-11]"},
+ {"operations_3yr", 1.0, 2.0, 4.0, "MUSD",
+ "3-yr campaign routine ops: 1-2 FTE at 150000-200000 USD/yr "
+ "fully-burdened plus mission-ops software 50000-200000 USD/yr "
+ "(SpaceNexus mission-cost guide); ops roughly 8pct of total mission "
+ "cost / 20pct of bus cost. C_ops (CU) tracks ~4 sim-days not the real "
+ "multi-year ops span -- this row is the real-dollar multi-year "
+ "figure. [web-verified (analog) 2026-07-11]"},
+ {"ssa_tracking", 0.0, 0.0, 0.0, "MUSD/yr",
+ "LeoLabs declines to publicly quote; subscription is customized by "
+ "fleet size and monitoring cadence (SpaceNews; CNBC). Zero-cost floor "
+ "pointer: USSF/18th SDS free public conjunction data messages (CDMs). "
+ "0/0/0 is an UNFILLED placeholder not a claim the service is free -- "
+ "do not invent a subscription figure. [PLACEHOLDER]"},
+ {"insurance_launch_plus_1yr_pct", 5.0, 8.0, 12.0, "% insured value",
+ "Launch-plus-first-year-in-orbit premium as pct of insured value; "
+ "range rises after major-loss years (2023-24 hardened the market) or "
+ "for unproven vehicles (Orbital Radar; Taylor Wessing; SpaceNews). "
+ "Many smallsat missions self-insure -- applied only in the "
+ "campaign_cost_musd_with_reserves row never in the base cost. "
+ "[web-verified 2026-07-11]"},
+ {"licensing_fcc_application", 0.030, 0.0375, 0.045, "MUSD",
+ "FCC Part 25 application fee one-time (not annual): streamlined "
+ "smallsat 30000 USD (6-yr license term) is low; standard 45000 USD "
+ "(15-yr term) is high; mid is the arithmetic midpoint (no published "
+ "intermediate tier). [web-verified 2026-07-11]"},
+ {"licensing_fcc_annual", 0.012215, 0.012215, 0.012215, "MUSD/yr",
+ "FCC annual regulatory fee NGSO Small Satellite category FY2024: "
+ "12215 USD per license/call sign (Federal Register FY2024 fee "
+ "assessment; FCC 25-31); category explicitly includes On-Orbit "
+ "Servicing (OOS) and Rendezvous and Proximity Operations (RPO) -- the "
+ "ADSC servicer class. Single published figure flat across low/mid/"
+ "high. [web-verified 2026-07-11]"},
+ {"kit_unit_scaled_9t", 0.1, 0.5, 2.0, "MUSD/unit",
+ "9-t-class installer deorbit kit; no citable product exists at this "
+ "scale. Extrapolated from Terminator Tape (50000-75000 USD/unit "
+ "CubeSat-scale lower-bound analog flight-proven) and dragNET (2.8 kg "
+ "module mass/design cited no public unit price) up to a custom "
+ "EDT/sail plus attach interface for a multi-tonne derelict; upper "
+ "bound is engineering judgment. [training-data extrapolation]"},
+ {"contingency_reserve_pct", 20.0, 25.0, 30.0, "% of cost",
+ "Standard NASA development-phase cost-reserve guideline for Class "
+ "C/D smallsat missions (NASA Cost Estimating Handbook v4.0; NASA "
+ "reserves lesson 1780; NASA TMC Class C/D expectations); applied to "
+ "the pre-reserve campaign cost in the campaign_cost_musd_with_"
+ "reserves row. [web-verified 2026-07-11]"},
+};
+constexpr int kNumWp14CostItems =
+ static_cast(sizeof(kWp14CostItems) / sizeof(kWp14CostItems[0]));
+
+const CostItemUsd* find_wp14_item(const char* name) {
+ for (int i = 0; i < kNumWp14CostItems; ++i)
+ if (std::strcmp(kWp14CostItems[i].item, name) == 0) return &kWp14CostItems[i];
+ return nullptr;
+}
+
+} // namespace
+
+const CostItemUsd* wp14_cost_items(int* count) {
+ if (count) *count = kNumWp14CostItems;
+ return kWp14CostItems;
+}
+
const char* weighting_label(Weighting w) {
switch (w) {
case Weighting::SpatialDensity: return "spatial_density";
@@ -287,16 +397,109 @@ void write_cost_summary_csv(const std::string& path, const CostConfig& cc,
if (!f) return;
std::fprintf(f,
"schema_version,catalog,record_type,n_kits,param,weighting,metric,"
- "estimate,p05,p50,p95,units,notes\n");
+ "estimate,p05,p50,p95,units,notes,cost_scenario\n");
+ // `cost_scenario` (WP14, schema 1.1) is an ADDITIVE trailing column: every
+ // pre-WP14 call site below omits it and gets the default "" (blank), so
+ // every existing row's VALUES are unchanged -- only a new blank field is
+ // appended. Only the new WP14 rows further down pass a real scenario.
const auto emit = [&](const char* cat, const char* rt, const std::string& nk,
const char* param, const char* weighting,
const char* metric, double est, double p05, double p50,
- double p95, const char* units, const char* notes) {
- std::fprintf(f, "%s,%s,%s,%s,%s,%s,%s,%.6f,%.6f,%.6f,%.6f,%s,\"%s\"\n",
+ double p95, const char* units, const char* notes,
+ const char* cost_scenario = "") {
+ std::fprintf(f, "%s,%s,%s,%s,%s,%s,%s,%.6f,%.6f,%.6f,%.6f,%s,\"%s\",%s\n",
wp6_schema_version(), cat, rt, nk.c_str(), param, weighting,
- metric, est, p05, p50, p95, units, notes);
+ metric, est, p05, p50, p95, units, notes, cost_scenario);
};
+ // ------------------------------------------------------------- WP14 setup
+ // Itemized USD table + the campaign-level anchor derived from it. The
+ // anchor's denominator (C_campaign_CU_p50 at the baseline N) is read from
+ // the SAME sweep already computed above for catalog A -- nothing here
+ // re-runs the campaign or touches a CU coefficient.
+ int wp14_n_items = 0;
+ const CostItemUsd* wp14_items = wp14_cost_items(&wp14_n_items);
+ const char* kScenario[3] = {"low", "mid", "high"};
+
+ const DebrisCatalog catA = catalog_A();
+ const CatalogCost* cqA = nullptr;
+ for (const CatalogCost& cq : report.catalogs)
+ if (std::strcmp(cq.catalog.name, catA.name) == 0) { cqA = &cq; break; }
+ const AmortPoint* baseAp = nullptr;
+ if (cqA)
+ for (const AmortPoint& a : cqA->amortization)
+ if (a.n_kits == cqA->baseline_n_kits) { baseAp = &a; break; }
+ const double denom_cu = baseAp ? baseAp->campaign_cost_p50 : 0.0;
+ const int baseline_n = cqA ? cqA->baseline_n_kits : 0;
+ const std::string baseline_nk = std::to_string(baseline_n);
+
+ const CostItemUsd* it_dev = find_wp14_item("development");
+ const CostItemUsd* it_bus = find_wp14_item("manufacturing_bus_unit");
+ const CostItemUsd* it_launch = find_wp14_item("launch_rideshare_sso");
+ const CostItemUsd* it_launch_ded = find_wp14_item("launch_dedicated_anchor");
+ const CostItemUsd* it_ground = find_wp14_item("ground_segment_3yr");
+ const CostItemUsd* it_ops = find_wp14_item("operations_3yr");
+ const CostItemUsd* it_fcc_app = find_wp14_item("licensing_fcc_application");
+ const CostItemUsd* it_fcc_annual = find_wp14_item("licensing_fcc_annual");
+ const CostItemUsd* it_kit = find_wp14_item("kit_unit_scaled_9t");
+ const CostItemUsd* it_ssa = find_wp14_item("ssa_tracking");
+ const CostItemUsd* it_insurance = find_wp14_item("insurance_launch_plus_1yr_pct");
+ const CostItemUsd* it_contingency = find_wp14_item("contingency_reserve_pct");
+ const auto scenario_val = [](const CostItemUsd* it, int s) {
+ if (!it) return 0.0;
+ return s == 0 ? it->low_musd : (s == 1 ? it->mid_musd : it->high_musd);
+ };
+
+ // C_launch term for the anchor CORE (Sec.10 Method, adjudicated B3):
+ // width preserved on purpose, NOT narrowed artificially -- low = the
+ // rideshare card low (launch_rideshare_sso.low), mid = the rideshare
+ // upper / heavier-rideshare-buy figure (launch_rideshare_sso.high), high
+ // = the dedicated Electron anchor (launch_dedicated_anchor.high, which
+ // equals .mid, both 7.5 MUSD).
+ const double c_launch_core[3] = {
+ it_launch ? it_launch->low_musd : 0.0,
+ it_launch ? it_launch->high_musd : 0.0,
+ it_launch_ded ? it_launch_ded->high_musd : 0.0,
+ };
+
+ // Anchor CORE = exactly the 5 CU-mapped components ONLY (Sec.10 Method,
+ // adjudicated B2): development + manufacturing_bus_unit +
+ // kit_unit_scaled_9t x baseline_n kits + C_launch [c_launch_core above]
+ // + operations_3yr. ADDITIVES sit on top of the core and are OUTSIDE the
+ // anchor numerator: ground_segment_3yr, licensing_fcc_application,
+ // licensing_fcc_annual x3 yr, and the PLACEHOLDER ssa_tracking row
+ // (value 0 either way). base_total = core + additives (B5-structure).
+ double core_musd[3];
+ double additives_musd[3];
+ double base_total_musd[3];
+ double anchor_musd_per_cu[3];
+ double insured_value_musd[3];
+ double insurance_line_musd[3];
+ double with_reserves_musd[3];
+ for (int s = 0; s < 3; ++s) {
+ core_musd[s] = scenario_val(it_dev, s) + scenario_val(it_bus, s) +
+ scenario_val(it_kit, s) * static_cast(baseline_n) +
+ c_launch_core[s] + scenario_val(it_ops, s);
+ additives_musd[s] = scenario_val(it_ground, s) + scenario_val(it_fcc_app, s) +
+ scenario_val(it_fcc_annual, s) * 3.0 + scenario_val(it_ssa, s);
+ base_total_musd[s] = core_musd[s] + additives_musd[s];
+ anchor_musd_per_cu[s] = (denom_cu > 0.0) ? core_musd[s] / denom_cu : 0.0;
+
+ // N5-insurance: insurance is an ADDITIVE line scaled to the
+ // asset+launch insured value (matching the source's illustration),
+ // NOT a multiplicative factor over base_total -- base_total also
+ // carries the multi-year ops/ground/licensing opex, which is not
+ // part of what gets insured.
+ insured_value_musd[s] = scenario_val(it_bus, s) +
+ scenario_val(it_kit, s) * static_cast(baseline_n) +
+ c_launch_core[s];
+ const double insurance_pct = scenario_val(it_insurance, s);
+ insurance_line_musd[s] = (insurance_pct / 100.0) * insured_value_musd[s];
+ const double contingency_pct = scenario_val(it_contingency, s);
+ with_reserves_musd[s] =
+ base_total_musd[s] * (1.0 + contingency_pct / 100.0) + insurance_line_musd[s];
+ }
+
for (const CatalogCost& cq : report.catalogs) {
for (const AmortPoint& a : cq.amortization) {
const std::string nk = std::to_string(a.n_kits);
@@ -322,6 +525,36 @@ void write_cost_summary_csv(const std::string& path, const CostConfig& cc,
emit(cq.catalog.name, "fom", "", "", weighting_label(fr.weighting),
"band_weight", fr.band_weight, fr.band_weight, fr.band_weight,
fr.band_weight, "normalized", "w(h) PLACEHOLDER; cite on fill");
+
+ // WP14 (e): inverse-FoM orientation, reusing the fom_p05/p50/p95
+ // already computed above (same congestion_weight() call, same
+ // per-run mass/cost -- no weight is recomputed here). 1/x is
+ // strictly decreasing for x>0, so the percentile order reverses:
+ // each cost_per_risk_equiv_mass percentile is the reciprocal of
+ // the FoM percentile BY CONSTRUCTION -- it is NOT the percentile
+ // of an independent per-run-reciprocal distribution (order
+ // statistics interpolate, so those two are not generally equal);
+ // p05/p95 swap to the reciprocal of fom's p95/p05.
+ const double risk_p50 = (fr.fom_p50 > 0.0) ? 1.0 / fr.fom_p50 : 0.0;
+ const double risk_p05 = (fr.fom_p95 > 0.0) ? 1.0 / fr.fom_p95 : 0.0;
+ const double risk_p95 = (fr.fom_p05 > 0.0) ? 1.0 / fr.fom_p05 : 0.0;
+ emit(cq.catalog.name, "fom", "", "", weighting_label(fr.weighting),
+ "cost_per_risk_equiv_mass", risk_p50, risk_p05, risk_p50, risk_p95,
+ "CU_per_kg",
+ "= C_campaign/sum(m_i*w(h_i)) = 1/fom per the same per-run FoM "
+ "machinery (weights not recomputed); p50 is the reciprocal of "
+ "the fom_p50 percentile BY CONSTRUCTION, NOT the percentile of "
+ "per-run reciprocals (order statistics interpolate); p05/p95 "
+ "are reciprocals of fom_p95/fom_p05 (order reverses for a "
+ "decreasing transform)");
+ emit(cq.catalog.name, "fom", "", "", weighting_label(fr.weighting),
+ "cost_per_risk_equiv_mass_musd",
+ risk_p50 * anchor_musd_per_cu[1], risk_p05 * anchor_musd_per_cu[1],
+ risk_p50 * anchor_musd_per_cu[1], risk_p95 * anchor_musd_per_cu[1],
+ "MUSD_per_kg",
+ "= cost_per_risk_equiv_mass[CU_per_kg] x anchor_musd_per_cu"
+ "[cost_scenario=mid]; mid-scenario anchor only to bound row count",
+ "mid");
}
const std::string bnk = std::to_string(cq.baseline_n_kits);
for (const CostComponent& comp : cq.baseline_components)
@@ -330,6 +563,122 @@ void write_cost_summary_csv(const std::string& path, const CostConfig& cc,
"baseline breakdown at median mission time");
}
+ // ------------------------------------------------------- WP14 (a): items
+ for (int i = 0; i < wp14_n_items; ++i) {
+ const CostItemUsd& it = wp14_items[i];
+ for (int s = 0; s < 3; ++s) {
+ const double v = scenario_val(&it, s);
+ emit("", "cost_component_musd", "", it.item, "", "cost", v, 0.0, v, 0.0,
+ it.unit, it.note, kScenario[s]);
+ }
+ }
+
+ // ------------------------------------------- WP14 (b): derived CU anchor
+ for (int s = 0; s < 3; ++s) {
+ char note[1024];
+ std::snprintf(note, sizeof(note),
+ "anchor_musd_per_cu[%s] = core_5_component / C_campaign_CU_p50; "
+ "core = development(%.4f) + manufacturing_bus_unit(%.4f) + "
+ "kit_unit_scaled_9t*%dkits(%.4f) + C_launch_core(%.4f) + "
+ "operations_3yr(%.4f) = %.6f MUSD; C_campaign_CU_p50(catalog=%s;"
+ "baseline N=%d)=%.6f CU; anchor=%.6f MUSD/CU. C_launch_core[%s]="
+ "%.4f MUSD (low=rideshare card low, mid=rideshare upper/heavier "
+ "buy, high=dedicated Electron -- launch_dedicated_anchor feeds "
+ "this high bound, width preserved not narrowed, B3). ONLY these "
+ "5 CU-mapped components are in the anchor core (Sec.10 Method, "
+ "B2): ground_segment_3yr, licensing_fcc_application, "
+ "licensing_fcc_annual*3yr and ssa_tracking (PLACEHOLDER) are "
+ "ADDITIVE, outside the core (see campaign_cost_musd additives/"
+ "base_total rows); insurance_launch_plus_1yr_pct and "
+ "contingency_reserve_pct apply only in campaign_cost_musd"
+ "[with_reserves]",
+ kScenario[s], scenario_val(it_dev, s), scenario_val(it_bus, s),
+ baseline_n, scenario_val(it_kit, s) * static_cast(baseline_n),
+ c_launch_core[s], scenario_val(it_ops, s), core_musd[s], catA.name,
+ baseline_n, denom_cu, anchor_musd_per_cu[s], kScenario[s],
+ c_launch_core[s]);
+ emit("", "currency_anchor_derived", baseline_nk, "", "", "anchor_musd_per_cu",
+ anchor_musd_per_cu[s], 0.0, anchor_musd_per_cu[s], 0.0, "musd_per_cu",
+ note, kScenario[s]);
+ }
+
+ // --------------------------------------------- WP14 (c): campaign cost
+ // Four rows per scenario, each separately visible (B5-structure) so the
+ // anchor's basis is auditable straight from the CSV: core_5_component
+ // (== currency_anchor_derived numerator), additives (outside the CU
+ // model, outside the anchor), base_total = core + additives, and
+ // with_reserves (N5-insurance: contingency multiplies base_total,
+ // insurance is an ADDITIVE line on insured_value, not a multiplier over
+ // the whole base_total -- see the with_reserves note below).
+ for (int s = 0; s < 3; ++s) {
+ char note_core[320];
+ std::snprintf(note_core, sizeof(note_core),
+ "campaign_cost_musd[core_5_component;%s] = the 5 CU-mapped "
+ "components (development+manufacturing_bus_unit+"
+ "kit_unit_scaled_9t*%dkits+C_launch_core+operations_3yr); "
+ "identical numerator to currency_anchor_derived = %.6f MUSD",
+ kScenario[s], baseline_n, core_musd[s]);
+ emit("", "campaign_cost_musd", baseline_nk, "", "", "core_5_component",
+ core_musd[s], 0.0, core_musd[s], 0.0, "MUSD", note_core, kScenario[s]);
+
+ char note_add[320];
+ std::snprintf(note_add, sizeof(note_add),
+ "campaign_cost_musd[additives;%s] = ground_segment_3yr + "
+ "licensing_fcc_application + licensing_fcc_annual*3yr + "
+ "ssa_tracking (PLACEHOLDER, 0); outside the CU model and outside "
+ "the anchor core (Sec.10 Method) = %.6f MUSD",
+ kScenario[s], additives_musd[s]);
+ emit("", "campaign_cost_musd", baseline_nk, "", "", "additives",
+ additives_musd[s], 0.0, additives_musd[s], 0.0, "MUSD", note_add,
+ kScenario[s]);
+
+ char note_base[320];
+ std::snprintf(note_base, sizeof(note_base),
+ "campaign_cost_musd[base_total;%s] = core_5_component(%.6f) + "
+ "additives(%.6f) = %.6f MUSD",
+ kScenario[s], core_musd[s], additives_musd[s], base_total_musd[s]);
+ emit("", "campaign_cost_musd", baseline_nk, "", "", "base_total",
+ base_total_musd[s], 0.0, base_total_musd[s], 0.0, "MUSD", note_base,
+ kScenario[s]);
+
+ char note2[640];
+ std::snprintf(note2, sizeof(note2),
+ "campaign_cost_musd[with_reserves;%s] = base_total(%.6f MUSD) * "
+ "(1+contingency_reserve_pct[%s]/100=%.4f) + insurance_line"
+ "(%.6f MUSD); insurance_line = insurance_launch_plus_1yr_pct[%s]"
+ "/100=%.4f * insured_value(manufacturing_bus_unit+"
+ "kit_unit_scaled_9t*%dkits+C_launch_core=%.6f MUSD) = %.6f MUSD "
+ "-- insurance scales with asset+launch value (N5), NOT "
+ "multiplied over the multi-year opex in base_total",
+ kScenario[s], base_total_musd[s], kScenario[s],
+ 1.0 + scenario_val(it_contingency, s) / 100.0, insurance_line_musd[s],
+ kScenario[s], scenario_val(it_insurance, s) / 100.0, baseline_n,
+ insured_value_musd[s], insurance_line_musd[s]);
+ emit("", "campaign_cost_musd", baseline_nk, "", "", "with_reserves",
+ with_reserves_musd[s], 0.0, with_reserves_musd[s], 0.0, "MUSD", note2,
+ kScenario[s]);
+ }
+
+ // --------------------------------------- WP14 (d): cost/removal in MUSD
+ for (int s = 0; s < 3; ++s) {
+ const double p05 = baseAp ? baseAp->cost_per_removal_p05 * anchor_musd_per_cu[s] : 0.0;
+ const double p50 = baseAp ? baseAp->cost_per_removal_p50 * anchor_musd_per_cu[s] : 0.0;
+ const double p95 = baseAp ? baseAp->cost_per_removal_p95 * anchor_musd_per_cu[s] : 0.0;
+ char note[384];
+ std::snprintf(note, sizeof(note),
+ "cost_per_removal_musd[%s] = cost_per_removal_CU(catalog=%s;"
+ "baseline N=%d; p05=%.4f p50=%.4f p95=%.4f) x "
+ "anchor_musd_per_cu[%s]=%.6f MUSD/CU",
+ kScenario[s], catA.name, baseline_n,
+ baseAp ? baseAp->cost_per_removal_p05 : 0.0,
+ baseAp ? baseAp->cost_per_removal_p50 : 0.0,
+ baseAp ? baseAp->cost_per_removal_p95 : 0.0, kScenario[s],
+ anchor_musd_per_cu[s]);
+ emit(catA.name, "cost_per_removal_musd", baseline_nk, "", "",
+ "cost_per_removal", p50, p05, p50, p95, "MUSD_per_removal", note,
+ kScenario[s]);
+ }
+
for (const TornadoRow& tr : report.tornado) {
char note[128];
std::snprintf(note, sizeof(note), "low=%.4f;high=%.4f (+/-%.0f%% one-at-a-time)",
@@ -347,7 +696,10 @@ void write_cost_summary_csv(const std::string& path, const CostConfig& cc,
emit("", "currency_anchor", "", "", "", "cu_to_musd_range", cc.cu_to_musd_low,
cc.cu_to_musd_low, 0.0, cc.cu_to_musd_high, "musd_per_cu",
- "PLACEHOLDER: fill a CITED range in WP7; no point-value dollar figure is claimed");
+ "SUPERSEDED sentinel (CostConfig::cu_to_musd_low/high stay 0.0 for "
+ "schema continuity); the anchor is now DERIVED AT RUNTIME from the "
+ "WP14 itemized cost table (see currency_anchor_derived rows) -- no "
+ "point-value dollar figure is claimed");
std::fclose(f);
}
@@ -361,11 +713,14 @@ void write_cost_summary_md(const std::string& path, const CostConfig& cc,
// fail the "CI regeneration == committed" reproducibility check (R6).
std::fprintf(f,
"Primary output is in **relative cost units (CU)**; **no point-value "
- "dollar figure is claimed** (R6/D10) -- the CU->currency anchor is a "
- "PLACEHOLDER cited range, filled in WP7. All cost/FoM parameters are "
- "PLACEHOLDER (see `CostConfig`). Cost, cost/removal and FoM are "
- "propagated **per run** from the WP5 campaign (schema 1.0) and reported "
- "as p05/p50/p95. Regenerate with `adsc_cost`.\n\n");
+ "dollar figure is claimed** (R6/D10) -- the CU->currency anchor is now "
+ "DERIVED AT RUNTIME from the WP14 cited itemized cost table (see the "
+ "`currency_anchor_derived` rows in the CSV), per cost_scenario "
+ "(low/mid/high); `CostConfig::cu_to_musd_low/high` are superseded "
+ "sentinels left at 0.0. All cost/FoM parameters are PLACEHOLDER (see "
+ "`CostConfig`). Cost, cost/removal and FoM are propagated **per run** "
+ "from the WP5 campaign (schema 1.0) and reported as p05/p50/p95. "
+ "Regenerate with `adsc_cost`.\n\n");
for (const CatalogCost& cq : report.catalogs) {
std::fprintf(f, "## %s\n\n", cq.catalog.name);
@@ -422,32 +777,51 @@ void write_cost_schema_md(const std::string& path) {
std::FILE* f = std::fopen(path.c_str(), "w");
if (!f) return;
std::fprintf(f,
-"# WP6 cost/FoM CSV schema (version 1.0)\n"
+"# WP6/WP14 cost/FoM CSV schema (version 1.1)\n"
"\n"
-"`generated/wp6_cost_summary.csv` is a long-format table consumed by WP7. It\n"
-"consumes the WP5 campaign (schema 1.0): the amortization sweep re-runs the WP5\n"
-"engine across the kit-inventory sweep at the fixed master seed, and the\n"
+"`generated/wp6_cost_summary.csv` is a long-format table consumed downstream.\n"
+"It consumes the WP5 campaign (schema 1.0): the amortization sweep re-runs the\n"
+"WP5 engine across the kit-inventory sweep at the fixed master seed, and the\n"
"baseline row (n_kits = CampaignConfig::kits_initial) reproduces\n"
"`generated/wp5_campaign_runs.csv` exactly. Primary units are RELATIVE cost\n"
-"units (CU); **no point-value currency is emitted** (R6/D10).\n"
+"units (CU); **no point-value currency is emitted** (R6/D10) -- absolute costs\n"
+"appear ONLY as cited low/mid/high ranges (WP14).\n"
+"\n"
+"Schema 1.1 (WP14) is an ADDITIVE bump over 1.0 (WP6): one new trailing\n"
+"column (`cost_scenario`), four new `record_type` values (cost_component_musd\n"
+"/ currency_anchor_derived / campaign_cost_musd / cost_per_removal_musd), and\n"
+"two new `metric` values under the existing `fom` record_type\n"
+"(cost_per_risk_equiv_mass / cost_per_risk_equiv_mass_musd). Every row that\n"
+"existed under schema 1.0 is byte-identical in VALUE under 1.1; the only\n"
+"change to those rows is the appended blank `cost_scenario` field.\n"
"\n"
"## Columns\n"
"\n"
"| column | meaning |\n"
"|---|---|\n"
-"| schema_version | WP6 schema id (`1.0`) |\n"
+"| schema_version | schema id (`1.1`) |\n"
"| catalog | class-level preset name (blank for global rows) |\n"
-"| record_type | amortization / fom / tornado / cost_component / currency_anchor |\n"
-"| n_kits | kits carried (amortization/cost_component rows); blank otherwise |\n"
-"| param | cost parameter (tornado / cost_component rows); blank otherwise |\n"
+"| record_type | amortization / fom / tornado / cost_component / "
+"currency_anchor / cost_component_musd / currency_anchor_derived / "
+"campaign_cost_musd / cost_per_removal_musd |\n"
+"| n_kits | kits carried (amortization/cost_component rows) or the baseline N\n"
+" the WP14 derived rows were computed at; blank otherwise |\n"
+"| param | cost parameter (tornado / cost_component rows) or the WP14 item id\n"
+" (cost_component_musd rows); blank otherwise |\n"
"| weighting | spatial_density / criticality (fom rows); blank otherwise |\n"
"| metric | the quantity named in the row |\n"
"| estimate | point value (p50 for distributions) |\n"
"| p05 / p50 / p95 | percentiles (distribution rows; 0 when not applicable) |\n"
-"| units | CU_per_removal / CU / count / ratio / kg / kg_per_CU / normalized / musd_per_cu |\n"
-"| notes | provenance / PLACEHOLDER caveats |\n"
+"| units | CU_per_removal / CU / count / ratio / kg / kg_per_CU / CU_per_kg /\n"
+" normalized / musd_per_cu / MUSD / MUSD_per_removal / MUSD_per_kg / MUSD_per_yr\n"
+" / MUSD_per_unit / percent-valued units carried in the row's own `units`\n"
+" string (e.g. \"pct of cost\") |\n"
+"| notes | provenance / PLACEHOLDER caveats / WP14 arithmetic strings |\n"
+"| cost_scenario | low / mid / high (WP14 rows only); blank for pre-WP14 rows\n"
+" and for rows (e.g. fom / cost_per_risk_equiv_mass in CU) that are not\n"
+" currency-scenario-dependent |\n"
"\n"
-"## record_type rows\n"
+"## record_type rows (schema 1.0, unchanged)\n"
"\n"
"- **amortization** (per catalog, per swept N): `cost_per_removal` [CU/removal,\n"
" p05/p50/p95], `cost_per_removal_ratio_to_n1` [ratio], `removals` [count p50],\n"
@@ -458,21 +832,75 @@ void write_cost_schema_md(const std::string& path) {
"- **fom** (per catalog): `removed_mass_per_mission` [kg p50], and per weighting\n"
" `fom` [kg/CU, p05/p50/p95] and `band_weight` [normalized w(h)]. FoM =\n"
" sum m_i*w(h_i)/C_campaign (spec §4). The two weightings are PLACEHOLDER and\n"
-" disagree on band priority (T5).\n"
+" disagree on band priority (T5). WP14 adds, per weighting: `cost_per_"
+" risk_equiv_mass` [CU_per_kg, p05/p50/p95] = C_campaign/sum(m_i*w(h_i)),\n"
+" the exact inverse orientation: each percentile is the reciprocal of the\n"
+" FoM percentile BY CONSTRUCTION (NOT the percentile of per-run\n"
+" reciprocals -- order statistics interpolate, so the two are not the same\n"
+" computation), with p05/p95 swapped since 1/x is decreasing; and\n"
+" `cost_per_risk_equiv_mass_musd` [MUSD_per_kg, cost_scenario=mid only, to\n"
+" bound row count] = the same quantity converted via the mid-scenario anchor.\n"
"- **cost_component** (per catalog, baseline N): C_dev / C_bus / C_kit_total /\n"
" C_launch / C_ops [CU] at the median mission time.\n"
"- **tornado** (representative catalog, baseline N): per cost parameter,\n"
" `cost_per_removal_swing` [CU] and `fom_swing` [kg/CU] under a +/- one-at-a-\n"
" time perturbation; rows are sorted by cost/removal swing.\n"
-"- **currency_anchor** (global): `cu_to_musd_range` -- a PLACEHOLDER cited\n"
-" range (estimate/p05 = low, p95 = high). WP6 never emits a point-value dollar\n"
-" figure; a filled cited range is a WP7 deliverable.\n"
+"- **currency_anchor** (global): `cu_to_musd_range` -- unfilled pre-WP14\n"
+" sentinel (CostConfig::cu_to_musd_low/high, both 0.0; SUPERSEDED, see\n"
+" currency_anchor_derived below). Retained byte-identical for schema\n"
+" continuity; it never carries a point-value dollar figure.\n"
+"\n"
+"## record_type rows (schema 1.1, new -- WP14)\n"
+"\n"
+"- **cost_component_musd** (global, one row per WP14 itemized cost row x\n"
+" cost_scenario): `metric=cost`, `estimate`/`p50` = the row's value at that\n"
+" scenario, `units` = the item's own unit (MUSD / MUSD/yr / MUSD/unit / a\n"
+" percentage unit), `param` = the item id, `notes` = the full cited source\n"
+" note (D10: every row is sourced-or-PLACEHOLDER; a PLACEHOLDER row's note\n"
+" ends `[PLACEHOLDER]` and its value is 0, never fabricated).\n"
+"- **currency_anchor_derived** (global, one row per cost_scenario):\n"
+" `metric=anchor_musd_per_cu` [musd_per_cu] = core_5_component / \n"
+" C_campaign_CU_p50 at the baseline N (catalog A). The core is EXACTLY the\n"
+" 5 CU-mapped components (Sec.10 Method): development +\n"
+" manufacturing_bus_unit + kit_unit_scaled_9t x baseline-N-kits + C_launch\n"
+" (low=rideshare card low, mid=rideshare upper, high=dedicated Electron --\n"
+" width preserved, not narrowed) + operations_3yr. ground_segment_3yr,\n"
+" licensing_fcc_application, licensing_fcc_annual, and the PLACEHOLDER\n"
+" ssa_tracking row are ADDITIVE and excluded from this core (see\n"
+" campaign_cost_musd below); so are the insurance/contingency percentage\n"
+" rows. The `notes` field prints the full arithmetic string, numerator AND\n"
+" denominator, so the figure is independently auditable from the row alone.\n"
+"- **campaign_cost_musd** (global, per cost_scenario), four separately\n"
+" visible rows so the anchor's basis is auditable: `metric=core_5_component`\n"
+" = the identical numerator as currency_anchor_derived, in MUSD;\n"
+" `metric=additives` = ground_segment_3yr + licensing_fcc_application +\n"
+" licensing_fcc_annual x3 yr + ssa_tracking (PLACEHOLDER, 0), outside the CU\n"
+" model and outside the anchor core; `metric=base_total` = core_5_component\n"
+" + additives; `metric=with_reserves` = base_total x (1 +\n"
+" contingency_reserve_pct/100) + insurance_line, where insurance_line =\n"
+" (insurance_launch_plus_1yr_pct/100) x insured_value and insured_value =\n"
+" manufacturing_bus_unit + kit_unit_scaled_9t x baseline-N-kits + C_launch\n"
+" (asset+launch scale only -- insurance is an ADDITIVE line, not a\n"
+" multiplier over the multi-year opex folded into base_total); `notes`\n"
+" carries the formula for each row.\n"
+"- **cost_per_removal_musd** (catalog A, baseline N, per cost_scenario):\n"
+" `metric=cost_per_removal` [MUSD_per_removal, p05/p50/p95] = the existing\n"
+" cost_per_removal CU percentiles at the baseline N times that scenario's\n"
+" anchor_musd_per_cu.\n"
+"\n"
+"## Honesty framing (D10, carried into every WP14 currency row)\n"
+"\n"
+"External absolute-cost estimation by an unaffiliated author is inherently\n"
+"low-standing; agencies re-run with their own parametric models; the relative\n"
+"CU results remain primary. No point-value currency figure is emitted:\n"
+"absolute costs appear ONLY as low/mid/high cited ranges.\n"
"\n"
-"## Future visualization / evidence (WP7) — not implemented here\n"
+"## Future visualization / evidence -- not implemented here\n"
"\n"
"The schema supports, without change: amortization curves, FoM-under-weightings\n"
-"bars with percentile whiskers, tornado bars, and the cost-component breakdown.\n"
-"WP6 emits none of these charts and predicts no absolute cost.\n");
+"bars with percentile whiskers, tornado bars, the cost-component breakdown, and\n"
+"the WP14 itemized/anchor/campaign-cost tables. This module emits none of\n"
+"these charts.\n");
std::fclose(f);
}
diff --git a/src/main_kit_trade.cpp b/src/main_kit_trade.cpp
index 4f929be..e826df4 100644
--- a/src/main_kit_trade.cpp
+++ b/src/main_kit_trade.cpp
@@ -424,8 +424,8 @@ void write_classc_md(const std::string& out_dir, const DebrisCatalog& zenit,
std::fprintf(f, "## Cost\n\n");
std::fprintf(f, "| candidate | cost | source |\n");
std::fprintf(f, "|---|---|---|\n");
- std::fprintf(f, "| Zenit-2 / SL-16 (catalog_A) | PLACEHOLDER until WP14 | WP14 cost-range itemization |\n");
- std::fprintf(f, "| Envisat-class (catalog_C) | PLACEHOLDER until WP14 | WP14 cost-range itemization |\n\n");
+ std::fprintf(f, "| Zenit-2 / SL-16 (catalog_A) | itemized low/mid/high ranges | wp6_cost_summary.csv cost_component_musd + campaign_cost_musd rows (WP14) |\n");
+ std::fprintf(f, "| Envisat-class (catalog_C) | not separately itemized | WP14 ranges are servicer-campaign-scale; a controlled-reentry mission needs its own estimate (open) |\n\n");
std::fprintf(f, "## `wp13_classC.csv` columns\n\n");
std::fprintf(f, "| column | meaning |\n");
diff --git a/tests/test_cost.cpp b/tests/test_cost.cpp
index e5e6229..a439fd9 100644
--- a/tests/test_cost.cpp
+++ b/tests/test_cost.cpp
@@ -7,6 +7,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -37,6 +38,78 @@ static const AmortPoint* at_n(const CatalogCost& cq, int n) {
return nullptr;
}
+// Manual QUOTE-AWARE comma split (NOT std::getline-on-stringstream: that
+// silently drops a trailing empty field, e.g. the blank cost_scenario column
+// on every pre-WP14 row, misaligning every column-index lookup below).
+// Quote-awareness is REQUIRED: several WP14 quoted notes fields contain
+// literal commas (e.g. the currency_anchor_derived arithmetic note), so a
+// plain comma split would shift the cost_scenario column out of position.
+// RFC-4180-lite: '"' toggles the quoted state; a doubled '""' inside quotes
+// is an escaped quote; commas inside quotes are field content. Quotes are
+// stripped from the returned fields.
+static std::vector split_csv_line(const std::string& line) {
+ std::vector f;
+ std::string cur;
+ bool quoted = false;
+ for (std::size_t i = 0; i < line.size(); ++i) {
+ const char c = line[i];
+ if (quoted) {
+ if (c == '"') {
+ if (i + 1 < line.size() && line[i + 1] == '"') { cur += '"'; ++i; }
+ else quoted = false;
+ } else {
+ cur += c;
+ }
+ } else if (c == '"') {
+ quoted = true;
+ } else if (c == ',') {
+ f.push_back(cur);
+ cur.clear();
+ } else {
+ cur += c;
+ }
+ }
+ f.push_back(cur);
+ return f;
+}
+
+// schema 1.1 column order: 0 schema_version 1 catalog 2 record_type 3 n_kits
+// 4 param 5 weighting 6 metric 7 estimate 8 p05 9 p50 10 p95 11 units 12 notes
+// 13 cost_scenario.
+static std::vector> parse_csv_rows(const std::string& csv_text) {
+ std::vector> rows;
+ std::stringstream ss(csv_text);
+ std::string line;
+ std::getline(ss, line); // header
+ while (std::getline(ss, line)) {
+ if (!line.empty()) rows.push_back(split_csv_line(line));
+ }
+ return rows;
+}
+
+// First row matching every non-null filter; returns the value at `col`
+// (std::atof) or a sentinel if no row matches. Pass nullptr to skip a filter,
+// or "" to require the column be literally blank.
+static double find_csv_val(const std::vector>& rows,
+ const char* record_type, const char* metric,
+ const char* weighting, const char* cost_scenario,
+ int col) {
+ for (const std::vector& f : rows) {
+ if (f.size() <= static_cast(col)) continue;
+ if (record_type && f[2] != record_type) continue;
+ if (metric && f[6] != metric) continue;
+ if (weighting && f[5] != weighting) continue;
+ if (cost_scenario && f[13] != cost_scenario) continue;
+ return std::atof(f[col].c_str());
+ }
+ return -999999.0; // sentinel: no matching row found
+}
+
+static bool ends_with(const std::string& s, const std::string& suffix) {
+ return s.size() >= suffix.size() &&
+ s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
+}
+
int main() {
const Config base;
const CostConfig cc;
@@ -179,14 +252,27 @@ int main() {
const std::string header = txt.substr(0, txt.find('\n'));
const char* cols[] = {"schema_version", "catalog", "record_type", "n_kits",
"param", "weighting", "metric", "estimate", "p05",
- "p50", "p95", "units", "notes"};
+ "p50", "p95", "units", "notes", "cost_scenario"};
for (const char* c : cols) CHECK(header.find(c) != std::string::npos);
const char* rts[] = {"amortization", "fom", "tornado", "cost_component",
- "currency_anchor"};
+ "currency_anchor", "cost_component_musd",
+ "currency_anchor_derived", "campaign_cost_musd",
+ "cost_per_removal_musd", "cost_per_risk_equiv_mass"};
for (const char* rt : rts) CHECK(txt.find(rt) != std::string::npos);
- // No point-value currency: '$' must never appear in any WP6 artifact,
- // and the CU->currency anchor is left unfilled (low = high = 0).
+ // No point-value currency: '$' must never appear in any WP6/WP14
+ // artifact (this scan runs over `txt`/md/sch AFTER the WP14 rows are
+ // written, so it covers the new cost_component_musd /
+ // currency_anchor_derived / campaign_cost_musd / cost_per_removal_musd
+ // / cost_per_risk_equiv_mass(_musd) rows too -- no separate scan
+ // needed). `CostConfig::cu_to_musd_low/high` are UNUSED SENTINELS,
+ // intentionally left at 0.0 post-WP14: the real WP14 currency anchor
+ // is derived at runtime from the cited itemized table (see
+ // `currency_anchor_derived` checks below) and written straight into
+ // the CSV -- it never round-trips through these two config fields, so
+ // their ==0.0 assert below is still exactly what should hold, just
+ // for a different reason than pre-WP14 ("not yet filled" ->
+ // "deliberately bypassed").
CHECK(txt.find('$') == std::string::npos);
CHECK(slurp(md).find('$') == std::string::npos);
CHECK(slurp(sch).find('$') == std::string::npos);
@@ -197,6 +283,79 @@ int main() {
CHECK(txt.find(wd) == std::string::npos);
CHECK(slurp(md).find(wd) == std::string::npos);
}
+
+ // 7b. WP14: itemized USD table + derived anchor/campaign-cost/
+ // cost-per-risk rows written into the same CSV.
+ {
+ // Table shape + D10 (sourced-or-PLACEHOLDER, never fabricated):
+ // every row's note is non-empty and ends with one of the four
+ // verified-status flags.
+ int n_items = 0;
+ const CostItemUsd* items = wp14_cost_items(&n_items);
+ CHECK(n_items >= 11);
+ for (int i = 0; i < n_items; ++i) {
+ CHECK(items[i].item != nullptr && items[i].item[0] != '\0');
+ CHECK(items[i].unit != nullptr && items[i].unit[0] != '\0');
+ const std::string note = items[i].note ? items[i].note : "";
+ CHECK(!note.empty());
+ CHECK(ends_with(note, "[web-verified 2026-07-11]") ||
+ ends_with(note, "[web-verified (analog) 2026-07-11]") ||
+ ends_with(note, "[training-data extrapolation]") ||
+ ends_with(note, "[PLACEHOLDER]"));
+ }
+
+ const std::vector> rows = parse_csv_rows(txt);
+
+ // currency_anchor_derived: a real, cited low 0.0);
+ CHECK(anchor_high > anchor_low);
+
+ // campaign_cost_musd: with_reserves (contingency applied over
+ // base_total, plus the additive insurance_line) strictly exceeds
+ // the pre-reserve base_total at every scenario that has a
+ // nonzero contingency pct; check the high scenario (contingency
+ // 30%, insurance 12% -- both nonzero, so the contingency
+ // multiplier is > 1 and insurance_line is a positive add-on, so
+ // the inequality is strict either way).
+ const double campaign_base_total_high =
+ find_csv_val(rows, "campaign_cost_musd", "base_total", nullptr,
+ "high", 7);
+ const double campaign_with_reserves_high =
+ find_csv_val(rows, "campaign_cost_musd", "with_reserves", nullptr,
+ "high", 7);
+ CHECK(campaign_base_total_high > 0.0);
+ CHECK(campaign_with_reserves_high > campaign_base_total_high);
+
+ // Inverse-FoM identity: cost_per_risk_equiv_mass (CU/kg) is
+ // DEFINED as the reciprocal of fom (kg/CU) at each percentile,
+ // with p05/p95 swapped (1/x is monotone-decreasing for x>0).
+ // Inverse-FoM identity BY CONSTRUCTION: each
+ // cost_per_risk_equiv_mass percentile is the reciprocal of the
+ // already-computed FoM percentile (NOT the percentile of a
+ // per-run-reciprocal distribution -- order statistics interpolate,
+ // so those would differ). The CSV stores the value through the
+ // fixed "%.6f" format, so the honest exact comparison is against
+ // the reciprocal ROUND-TRIPPED through the same format: identical
+ // strings parse to identical doubles.
+ const double csv_risk_p50_criticality = find_csv_val(
+ rows, "fom", "cost_per_risk_equiv_mass", "criticality", "", 9);
+ const double fom_p50_criticality =
+ report.catalogs.front().fom[1].fom_p50; // index 1 == Criticality
+ CHECK(report.catalogs.front().fom[1].weighting == Weighting::Criticality);
+ CHECK(fom_p50_criticality > 0.0);
+ CHECK(csv_risk_p50_criticality > 0.0);
+ char expect_buf[64];
+ std::snprintf(expect_buf, sizeof expect_buf, "%.6f",
+ 1.0 / fom_p50_criticality);
+ CHECK(std::abs(csv_risk_p50_criticality - std::atof(expect_buf)) < 1e-15);
+ }
}
// 8. WP5-summary consumer (schema 1.0): parse a synthetic summary row.
diff --git a/tools/evidence/make_evidence.py b/tools/evidence/make_evidence.py
index 51159a1..402039f 100644
--- a/tools/evidence/make_evidence.py
+++ b/tools/evidence/make_evidence.py
@@ -18,6 +18,7 @@
import csv
import json
import os
+import re
import sys
OUT_REL = os.path.join("evidence", "adsc_evidence_pack.md")
@@ -56,6 +57,8 @@ def __init__(self, root):
self.wp12 = load_csv(os.path.join(g, "wp12_ladder.csv"))
self.wp13 = load_csv(os.path.join(g, "wp13_kit_trade.csv"))
self.wp13c = load_csv(os.path.join(g, "wp13_classC.csv"))
+ with open(os.path.join(g, "wp6_cost_schema.md"), encoding="utf-8") as f:
+ self.wp6_schema_md = f.read()
with open(os.path.join(g, "t6_flux_sweep.md"), encoding="utf-8") as f:
self.t6 = {}
for line in f:
@@ -92,14 +95,50 @@ def wp12v(self, section, level, catalog, metric, item=""):
return float(r["value"])
raise KeyError((section, level, catalog, metric, item))
- def wp6q(self, catalog, record_type, metric, n_kits=None, weighting=None):
+ def wp6q(self, catalog, record_type, metric, n_kits=None, weighting=None,
+ cost_scenario=None):
for r in self.wp6:
if (r["catalog"] == catalog and r["record_type"] == record_type
and r["metric"] == metric
and (n_kits is None or r["n_kits"] == str(n_kits))
- and (weighting is None or r["weighting"] == weighting)):
+ and (weighting is None or r["weighting"] == weighting)
+ and (cost_scenario is None
+ or r["cost_scenario"] == cost_scenario)):
return r
- raise KeyError((catalog, record_type, metric, n_kits, weighting))
+ raise KeyError((catalog, record_type, metric, n_kits, weighting,
+ cost_scenario))
+
+ def wp6_item(self, param, cost_scenario):
+ """One WP14 itemized cost_component_musd row (global; catalog blank),
+ keyed by its item id (`param` column) and cost_scenario."""
+ for r in self.wp6:
+ if (r["record_type"] == "cost_component_musd"
+ and r["param"] == param
+ and r["cost_scenario"] == cost_scenario):
+ return r
+ raise KeyError(("wp6_item", param, cost_scenario))
+
+ def md_section(self, md_text, heading):
+ """The body text of one '## heading' section of a committed markdown
+ doc, as a single collapsed paragraph (blank lines dropped, internal
+ newlines joined with spaces) -- used to quote a doc section verbatim
+ without hand-retyping it, so the generator and the source cannot
+ drift apart."""
+ lines = md_text.splitlines()
+ start = None
+ for i, ln in enumerate(lines):
+ if ln.strip() == heading:
+ start = i + 1
+ break
+ if start is None:
+ raise KeyError(("md_section: heading not found", heading))
+ body = []
+ for ln in lines[start:]:
+ if ln.startswith("## "):
+ break
+ if ln.strip():
+ body.append(ln.strip())
+ return " ".join(body)
def wp3_area25(self, catalog):
for r in self.wp3:
@@ -137,6 +176,29 @@ def f4(v):
return "%.4f" % float(v)
+def f6(v):
+ return "%.6f" % float(v)
+
+
+def note_lead(notes):
+ """The first sentence of a CSV `notes` field (up to the first '. '),
+ extracted mechanically rather than hand-retyping the whole (sometimes
+ long, third-party-citation-bearing) note."""
+ return notes.split(". ", 1)[0].rstrip(".") + "."
+
+
+def note_tag(notes):
+ """The trailing bracketed provenance tag of a CSV `notes` field (e.g.
+ '[PLACEHOLDER]' / '[training-data extrapolation]'), extracted
+ mechanically via the same bracket convention D10 already requires every
+ itemized cost row's note to end with."""
+ m = re.search(r"\[[^\[\]]*\]\s*$", notes.strip())
+ if not m:
+ raise ValueError("note has no trailing bracketed provenance tag: %r"
+ % notes)
+ return m.group(0)
+
+
def ko_claim(row, n_runs, ds_id):
"""R14-tagged keep-out claim from a WP5 keep_out_violation_rate row:
'**k of n** [L0: linear CW, dispersion set ds-v1, Wilson 95% upper bound
@@ -197,6 +259,66 @@ def collect_placeholders(root):
return hits
+# ---- PLACEHOLDER importance classification (spec:260-264) ------------------
+# A small, explicit path+keyword rule table, evaluated top-to-bottom (first
+# match wins). Intent, not line-level precision (a handful of borderline
+# comment lines land one bucket over from a stricter reading -- acceptable
+# for a "reviewer triage" classification, not a formal audit):
+# decision-critical: the mark sits on, or documents, a value that DIRECTLY
+# re-bases a headline CU/MUSD/FoM number or a campaign safety/outcome
+# rate if filled -- cost.hpp's CU coefficients and FoM weight tables
+# (the entire file: every mark there is part of that one system), the
+# cu-anchor itemized-cost-table rows in cost.cpp, decay.hpp's EdtConfig
+# physical parameters, campaign.hpp's Delta-v/time dispersions and the
+# capture-contact closing-speed threshold, mission.hpp's catalog-like
+# target physical parameters, and decay.cpp's catalog mass/altitude/
+# inclination constructors.
+# cosmetic: the mark appears only inside a print/log statement or a
+# doc-generator string being written into a schema/report markdown file
+# -- never on the value itself (detected by shape: a printf/fprintf call,
+# a raw string-literal continuation line, or a literal PLACEHOLDER string
+# used only as a test-assertion target).
+# moderate: everything else (structural comments, lower-priority /
+# secondary-fidelity-ladder parameters not named above) -- the default.
+IMPORTANCE_WHOLE_FILE_CRITICAL = (
+ "include/adsc/cost.hpp", # CU coefficients + FoM weight tables + cu-anchor doc
+ "src/decay.cpp", # catalog mass/altitude/inclination constructors
+)
+IMPORTANCE_KEYWORD_CRITICAL = {
+ "include/adsc/decay.hpp": ("kit_mass_kg", "avg_current_a", "eta_libration",
+ "deploy_failure_prob"),
+ "include/adsc/campaign.hpp": ("dv_", "t_attach_s", "t_depart_s",
+ "t_phasing_s", "disp_", "nominal_closing_m_s",
+ "nominal_solar_factor"),
+ "include/adsc/mission.hpp": ("target_altitude_km", "target_inertia_diag",
+ "sync_target_rate_deg_s",
+ "servicer_drag_area_m2",
+ "solar_min_density_factor",
+ "solar_max_density_factor"),
+}
+# cost.cpp's itemized-cost-table rows are C++ aggregate-initializer entries
+# ending "...]"...}," (e.g. the ssa_tracking row) -- distinct from that same
+# file's surrounding prose/print-string lines.
+COST_CPP_ITEM_ROW_RE = re.compile(r'\]"\s*\}')
+# A print/log statement or a raw string-literal continuation line: printf(
+# call sites, lines that are themselves just a quoted string fragment, or
+# fragments ending in an escaped newline-then-quote (a printed line of text).
+PRINT_STRING_RE = re.compile(r'printf\(|^\s*"|\\n"\s*[\),;]*\s*$')
+
+
+def classify_importance(rel, txt):
+ if rel in IMPORTANCE_WHOLE_FILE_CRITICAL:
+ return "decision-critical"
+ if rel == "src/cost.cpp" and COST_CPP_ITEM_ROW_RE.search(txt):
+ return "decision-critical"
+ kws = IMPORTANCE_KEYWORD_CRITICAL.get(rel)
+ if kws and any(k in txt for k in kws):
+ return "decision-critical"
+ if PRINT_STRING_RE.search(txt) or 'check("PLACEHOLDER"' in txt:
+ return "cosmetic"
+ return "moderate"
+
+
def build(d):
A, B = d.catalogs[0], d.catalogs[1]
Ashort, Bshort = A.split("/")[0].strip(), B.split("/")[0].strip()
@@ -678,9 +800,10 @@ def build(d):
w("The amortization curve (section 1) bottoms at N=%d because the **Delta-v"
% nmin)
w("budget, not the kit count, caps removals** - the honest capacity story a")
- w("mission designer needs. Cost is RELATIVE (CU) throughout: **no absolute")
- w("cost is predicted anywhere in this package**; the CU-to-currency anchor is")
- w("a deliberately unfilled cited-range PLACEHOLDER. Parameter sensitivity is")
+ w("mission designer needs. Cost is RELATIVE (CU) throughout and stays the")
+ w("PRIMARY metric; WP14 additionally derives a cited absolute-cost RANGE")
+ w("(never a point value) from an itemized cost table, detailed in the")
+ w("'Absolute cost ranges (WP14)' subsection below. Parameter sensitivity is")
w("ranked by a one-at-a-time tornado (development cost dominates):")
w("")
w("")
@@ -695,6 +818,84 @@ def build(d):
w("")
w("")
w("")
+ w("### Absolute cost ranges (WP14)")
+ w("")
+ w("Spec D10 rule, quoted verbatim: \"Every row carries a source or stays")
+ w("PLACEHOLDER - this is D10 applied, not relaxed. Relative CU results")
+ w("remain primary.\" (adsc-specification-v5.md section 6, WP14). Every")
+ w("number in this subsection is read from the new WP14 rows of the")
+ w("committed `generated/wp6_cost_summary.csv` (schema %s) - nothing"
+ % d.wp6[0]["schema_version"])
+ w("hand-written.")
+ w("")
+ anchor = {s: d.wp6q("", "currency_anchor_derived", "anchor_musd_per_cu",
+ cost_scenario=s) for s in ("low", "mid", "high")}
+ base_total = {s: d.wp6q("", "campaign_cost_musd", "base_total",
+ cost_scenario=s) for s in ("low", "mid", "high")}
+ with_res = {s: d.wp6q("", "campaign_cost_musd", "with_reserves",
+ cost_scenario=s) for s in ("low", "mid", "high")}
+ cpr_musd = {s: d.wp6q(A, "cost_per_removal_musd", "cost_per_removal",
+ cost_scenario=s) for s in ("low", "mid", "high")}
+ w("**CU->currency anchor**, derived at runtime from a 5-component core of")
+ w("the itemized cost table (development + manufacturing_bus_unit +")
+ w("kit_unit_scaled_9t x baseline-N-kits + launch + operations_3yr): low")
+ w("**%s** MUSD/CU .. high **%s** MUSD/CU (mid %s MUSD/CU); low+high anchor"
+ % (f6(anchor["low"]["estimate"]), f6(anchor["high"]["estimate"]),
+ f6(anchor["mid"]["estimate"])))
+ w("values themselves come only from cited itemized rows (D10) - never a")
+ w("point-value dollar figure.")
+ w("")
+ w("| cost_scenario | anchor [MUSD/CU] | campaign base_total [MUSD] |"
+ " campaign with_reserves [MUSD] |")
+ w("|---|---:|---:|---:|")
+ for s in ("low", "mid", "high"):
+ w("| %s | %s | %s | %s |" % (s, f6(anchor[s]["estimate"]),
+ f2(base_total[s]["estimate"]),
+ f2(with_res[s]["estimate"])))
+ w("")
+ w("`base_total` is the 5-component anchor core plus the additive")
+ w("ground-segment/licensing/SSA-tracking rows (all outside the CU model);")
+ w("`with_reserves` additionally applies the contingency-reserve percentage")
+ w("and an asset+launch-scaled insurance line (arithmetic provenance for")
+ w("both is printed in full in each row's own `notes` field, see the CSV).")
+ w("")
+ w("**Cost per removal (%s class, baseline N, MUSD, p50 per"
+ % Ashort)
+ w("cost_scenario):** low **%s** / mid **%s** / high **%s** MUSD/removal."
+ % (f3(cpr_musd["low"]["p50"]), f3(cpr_musd["mid"]["p50"]),
+ f3(cpr_musd["high"]["p50"])))
+ cost_per_risk_cu = d.wp6q(A, "fom", "cost_per_risk_equiv_mass",
+ weighting="criticality")
+ cost_per_risk_musd = d.wp6q(A, "fom", "cost_per_risk_equiv_mass_musd",
+ weighting="criticality", cost_scenario="mid")
+ w("**Cost per risk-equivalent mass (criticality weighting), p50:** %s"
+ % f6(cost_per_risk_cu["p50"]))
+ w("CU/kg (%s MUSD/kg, mid cost_scenario) - the exact inverse orientation"
+ % f6(cost_per_risk_musd["p50"]))
+ w("of the FoM above (1/FoM by construction, not an independent")
+ w("computation - see the CSV row's own notes for the reciprocal-order-")
+ w("statistics caveat).")
+ w("")
+ ssa_row = d.wp6_item("ssa_tracking", "mid")
+ kit_row = d.wp6_item("kit_unit_scaled_9t", "mid")
+ w("**PLACEHOLDER/extrapolation flags, extracted (never hand-retyped)")
+ w("from the committed CSV's own notes -- the leading clause plus the")
+ w("trailing bracketed provenance tag, D10:**")
+ w("")
+ w("- `ssa_tracking`: \"%s\" ... %s"
+ % (note_lead(ssa_row["notes"]), note_tag(ssa_row["notes"])))
+ w("- `kit_unit_scaled_9t`: \"%s\" ... %s"
+ % (note_lead(kit_row["notes"]), note_tag(kit_row["notes"])))
+ w("")
+ honesty = d.md_section(
+ d.wp6_schema_md,
+ "## Honesty framing (D10, carried into every WP14 currency row)")
+ w("Honesty framing, quoted verbatim from `generated/wp6_cost_schema.md`")
+ w("(never hand-retyped, so the generator and the schema doc cannot")
+ w("drift apart):")
+ w("")
+ w("> %s" % honesty)
+ w("")
w("## 6. Limitations - stated plainly, none hidden")
w("")
w("- **Linear CW scope (F2):** every passive-safety statement is exact only in")
@@ -806,9 +1007,10 @@ def build(d):
w("```")
w("")
w("`tools/regenerate_all.sh` is the single source of truth for regeneration")
- w("order (campaign -> cost -> decay -> flux -> reference metrics -> figures ->")
- w("compliance -> this document); CI runs exactly this script and enforces the")
- w("byte-identity gate on every push. Measured wall time is printed to the CI")
+ w("order (campaign -> cost -> decay -> kit trade -> prioritization -> flux ->")
+ w("reference metrics -> figures -> compliance -> this document); CI runs")
+ w("exactly this script and enforces the byte-identity gate on every push.")
+ w("Measured wall time is printed to the CI")
w("log (see the latest `build-and-test` run); it is intentionally never")
w("embedded in artifacts, and it is well under the one-hour reproduction")
w("budget (D10). Requirements: C++17 compiler, Eigen 3.3+, CMake, Python 3")
@@ -821,15 +1023,33 @@ def build(d):
w("(R10), collected automatically by the generator of this document")
w("(`tools/evidence/` itself is excluded from the scan - it names the marker")
w("only in order to collect and audit it). If a value is listed here, treat")
- w("it as unvalidated until a cited source replaces it.")
+ w("it as unvalidated until a cited source replaces it. Each mark also")
+ w("carries a machine-assigned **importance** (spec:260-264): whether filling")
+ w("it in with a citation would directly re-base a headline CU/MUSD/FoM")
+ w("number or campaign safety/outcome rate (`decision-critical`), whether it")
+ w("is a lower-priority or secondary-fidelity parameter (`moderate`), or")
+ w("whether the mark appears only in a print/log statement or doc-generator")
+ w("string, never on the value itself (`cosmetic`). Classification rule")
+ w("table: `tools/evidence/make_evidence.py::classify_importance` (a small")
+ w("explicit path+keyword table, not a formal per-line audit -- a handful of")
+ w("comment lines may land one bucket over from a stricter reading).")
w("")
hits = collect_placeholders(d.root)
- w("Total marks: **%d**" % len(hits))
- w("")
- w("| location | line |")
- w("|---|---|")
+ counts = {"decision-critical": 0, "moderate": 0, "cosmetic": 0}
+ classified = []
for rel, i, txt in hits:
- w("| `%s:%d` | %s |" % (rel, i, txt))
+ imp = classify_importance(rel, txt)
+ counts[imp] += 1
+ classified.append((rel, i, txt, imp))
+ w("Total marks: **%d** (decision-critical: **%d**, moderate: **%d**, "
+ "cosmetic: **%d**)"
+ % (len(hits), counts["decision-critical"], counts["moderate"],
+ counts["cosmetic"]))
+ w("")
+ w("| location | importance | line |")
+ w("|---|---|---|")
+ for rel, i, txt, imp in classified:
+ w("| `%s:%d` | %s | %s |" % (rel, i, imp, txt))
w("")
w("## 11. Changelog - R15 pin supersessions")
w("")
diff --git a/tools/evidence/test_evidence.py b/tools/evidence/test_evidence.py
index 3aa3be2..97c98bc 100644
--- a/tools/evidence/test_evidence.py
+++ b/tools/evidence/test_evidence.py
@@ -203,14 +203,36 @@ def must_contain(s, why):
must_contain("PASS=%d, INFO=%d, WARN=%d, BLOCK=%d" %
(cs["PASS"], cs["INFO"], cs["WARN"], cs["BLOCK"]),
"compliance summary counts")
- # PLACEHOLDER inventory is non-empty and counted
- m = re.search(r"Total marks: \*\*(\d+)\*\*", pack)
- check(m is not None, "PLACEHOLDER inventory lacks a total count")
+ # PLACEHOLDER inventory is non-empty, counted, and importance-classified
+ # (spec:260-264): "Total marks: **N** (decision-critical: **X**,
+ # moderate: **Y**, cosmetic: **Z**)".
+ m = re.search(
+ r"Total marks: \*\*(\d+)\*\* \(decision-critical: \*\*(\d+)\*\*, "
+ r"moderate: \*\*(\d+)\*\*, cosmetic: \*\*(\d+)\*\*\)", pack)
+ check(m is not None,
+ "PLACEHOLDER inventory lacks the importance-classified total-count line")
if m:
+ n_total, n_crit, n_mod, n_cosmetic = (int(g) for g in m.groups())
n_rows = len(re.findall(r"\n\| `[^`]+:\d+` \|", pack))
- check(int(m.group(1)) == n_rows,
- "inventory count %s != table rows %d" % (m.group(1), n_rows))
+ check(n_total == n_rows,
+ "inventory count %d != table rows %d" % (n_total, n_rows))
check(n_rows > 20, "inventory suspiciously small (%d)" % n_rows)
+ check(n_crit + n_mod + n_cosmetic == n_total,
+ "decision-critical(%d) + moderate(%d) + cosmetic(%d) != total(%d)"
+ % (n_crit, n_mod, n_cosmetic, n_total))
+ # Sanity floor, not a pin: as of this writing the actual count is 56;
+ # 20 is chosen comfortably below that so incidental PLACEHOLDER-line
+ # rewording elsewhere doesn't flip this test, while still catching a
+ # real regression (e.g. the classifier silently stops matching).
+ check(n_crit >= 20,
+ "decision-critical count (%d) is below the 20-item sanity floor"
+ % n_crit)
+ # The importance column itself is present and every value recognized.
+ check("| location | importance | line |" in pack,
+ "inventory table lacks the importance column header")
+ for label in ("decision-critical", "moderate", "cosmetic"):
+ check(("| %s |" % label) in pack,
+ "inventory table has no %s-classified row" % label)
# 4. Determinism + committed == regenerated. The committed pack content was
# captured into `pack` above; the generator then intentionally rewrites the
diff --git a/tools/prioritization/make_prioritization.py b/tools/prioritization/make_prioritization.py
new file mode 100644
index 0000000..ede4327
--- /dev/null
+++ b/tools/prioritization/make_prioritization.py
@@ -0,0 +1,643 @@
+#!/usr/bin/env python3
+"""WP14 Prioritization table -> generated/wp14_prioritization.{csv,md}
+
+A pure derived/joined view over three already-committed, already-regenerated
+CSVs -- NO new simulation, NO new statistics. Every number below is either
+read directly from a committed row, or is a single elementwise arithmetic
+combination of two already-committed numbers (always noted where it occurs,
+e.g. cost_per_removal_musd for a catalog that has no dedicated WP6 anchor row
+is computed as CU_percentile x anchor_musd_per_cu -- exactly the same
+multiplication WP6/WP14 already performs for the anchor catalog itself).
+Zero hand-written numbers. Python 3 standard library only (spec v5 R9).
+
+Inputs (schema versions read from the files themselves, never hardcoded):
+ generated/wp6_cost_summary.csv (schema 1.1: cost/FoM/currency-anchor rows)
+ generated/wp13_kit_trade.csv (schema 1.0: per-class kit/EDT/sail trade)
+ generated/wp5_campaign_summary.csv (schema 1.1: campaign Monte Carlo rates)
+
+Discipline (spec v5 R6/D10/R11):
+ * No wall-clock timestamp or run time is embedded in any output.
+ * All numeric fields use fixed-decimal formatting so the byte output is
+ stable across platforms and runs (the CI "regeneration == committed"
+ gate).
+ * No point-value currency figure is ever printed -- absolute-cost numbers
+ appear ONLY as low/mid/high cited ranges (the same D10 discipline as
+ wp6_cost_summary.csv).
+ * D12: legal accessibility enters as a gate + metadata flags via the WP8
+ compliance engine, never as a multiplier in the FoM or in any rank score
+ computed here.
+ * ASCII only, LF-only line endings.
+
+Usage: make_prioritization.py [out_dir]
+ out_dir defaults to /generated
+"""
+import csv
+import os
+import sys
+
+CAT_A = "SL-16 / Zenit-2 second stage"
+CAT_B = "SL-8 / Kosmos-3M second stage"
+CAT_C = "Envisat-class massive SSO payload"
+
+WEIGHTINGS = ("spatial_density", "criticality")
+SCENARIOS = ("low", "mid", "high")
+
+CSV_HEADER = [
+ "schema_version", "catalog", "record_type", "metric", "weighting",
+ "cost_scenario", "kit_option", "estimate", "p05", "p50", "p95",
+ "value_lo", "value_hi", "units", "notes",
+]
+SCHEMA_VERSION = "1.0"
+
+
+# ---- fixed formatting (platform-stable) ------------------------------------
+def f0(v):
+ return "%.0f" % float(v)
+
+
+def f1(v):
+ return "%.1f" % float(v)
+
+
+def f2(v):
+ return "%.2f" % float(v)
+
+
+def f3(v):
+ return "%.3f" % float(v)
+
+
+def f4(v):
+ return "%.4f" % float(v)
+
+
+def f6(v):
+ return "%.6f" % float(v)
+
+
+def band_str(lo, hi, fmt):
+ """'lo..hi' honoring the wp13_kit_trade.csv convention that a non-numeric
+ band field (e.g. the exact-polar 'n/a (...)' text) is the honest physical
+ null result, not missing data -- printed verbatim rather than reformatted."""
+ try:
+ return "%s..%s" % (fmt(float(lo)), fmt(float(hi)))
+ except ValueError:
+ return "%s .. %s" % (lo, hi)
+
+
+def load_rows(path):
+ with open(path, newline="", encoding="utf-8") as f:
+ return list(csv.DictReader(f))
+
+
+def write_text(path, s):
+ # newline="\n" forces LF on every platform so committed bytes are stable.
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
+ f.write(s)
+
+
+# ---- literature citations (D2/D12; hand-written prose, not data -- the same
+# ---- convention tools/evidence/make_evidence.py already uses for its own
+# ---- Kessler/Liou/McKnight citations) --------------------------------------
+MCKNIGHT_CITATION = (
+ "McKnight, D., Witner, R., Letizia, F., Lemmens, S., Anselmo, L., "
+ "Pardini, C., Rossi, A., Kunstadter, C., et al. (2021), \"Identifying "
+ "the 50 statistically-most-concerning derelict objects in LEO,\" Acta "
+ "Astronautica 181, 282-291, doi:10.1016/j.actaastro.2021.01.021 "
+ "(wp13-literature.md Topic 6): 11 expert teams' consensus top-50 most-"
+ "concerning LEO objects is dominated by rocket bodies, and the top ~20 "
+ "are ALL SL-16 (Zenit-2) second stages (average mass of the top-50 "
+ "objects ~5,295 kg); removing the top-50 would roughly halve LEO "
+ "collision risk (top-10 ~30% reduction)."
+)
+SL8_POPULATION_CITATION = (
+ "wp13-literature.md Topic 5b: SL-8 (Kosmos-3M) second-stage clusters "
+ "span three altitude boxes -- Box 3 ~700-800 km (50 R/Bs), Box 2 "
+ "~900-1000 km (143 R/Bs), Box 1 ~1500-1600 km (44 R/Bs), inclinations "
+ "~74/83 deg -- a combined population an order of magnitude larger than "
+ "the SL-16 top-20 concern list; the class appears prominently in SL-8-"
+ "specific ADR mission studies (e.g. ADReS-A, Springer "
+ "10.1007/978-3-319-15982-9_3)."
+)
+ENVISAT_CITATION = (
+ "wp13-literature.md Topic 6 item 1: Envisat (ESA), mass 8,211 kg "
+ "(Service Module 2,673 + PEB 1,021 + Payload Carrier 2,078 + Fuel 319 "
+ "+ Instruments 2,118 kg), sun-synchronous orbit ~765-800 km, "
+ "inclination ~98.4-98.55 deg, defunct since April 2012; the archetypal "
+ "'too massive / high-casualty-risk-on-uncontrolled-reentry' object "
+ "cited across the debris-mitigation literature (eoPortal; ESA Earth "
+ "Online)."
+)
+D12_SENTENCE = (
+ "Legal accessibility enters as a gate and metadata flags, never a "
+ "multiplier (D12, adsc-specification-v5.md section 3): the WP8 "
+ "compliance engine (`tools/compliance/check_compliance.py`, policy "
+ "`ADSC-POL-01`) BLOCKs unconsented active debris removal, and this "
+ "prioritization table consumes its PASS/BLOCK output and metadata "
+ "flags rather than folding a subjective legal weight into the FoM or "
+ "into any rank below. No catalog in this document is ranked, up or "
+ "down, on legal/consent grounds -- consent is a gate, not a scoring "
+ "input."
+)
+HONESTY_NOTE = (
+ "The criticality weighting's citation basis is McKnight-anchored at "
+ "the ~840-850 km peak (McKnight et al. 2021: the top ~20 most-"
+ "concerning LEO objects are all SL-16/Zenit-2 second stages, ~9 t "
+ "class, ~850 km -- exactly catalog A's band), which is a real, cited "
+ "anchor point for catalog A's criticality weight w(h=840)=1.00. But "
+ "the REST of the criticality weighting table "
+ "(`CostConfig::weight_table`, the non-peak altitude/weight pairs away "
+ "from 840-850 km) remains PLACEHOLDER: the McKnight ranking gives a "
+ "strong, cited reason to weight the ~840-850 km peak high, but it "
+ "does not by itself supply a citable functional form for how the "
+ "weight falls off away from that peak, so the shape between anchor "
+ "points is still an uncited interpolation (T5 open). The dressed-up-"
+ "mass-sort critique from the WP14 review is acknowledged here, not "
+ "dismissed: because both weighting tables today have limited dynamic "
+ "range and removed mass dominates the FoM ratio, a skeptical reader "
+ "is right to ask whether the 'criticality' weighting is doing real "
+ "discriminating work beyond re-deriving a mass ranking; strengthening "
+ "the non-peak citation basis (or accepting the narrower dynamic range "
+ "as the honest state of the evidence) is the open item, not resolved "
+ "by this document."
+)
+
+
+class Data:
+ """Reads the three committed CSVs; exposes only values that appear in
+ them (no hand-written numbers)."""
+
+ def __init__(self, gen):
+ self.wp6 = load_rows(os.path.join(gen, "wp6_cost_summary.csv"))
+ self.wp13 = load_rows(os.path.join(gen, "wp13_kit_trade.csv"))
+ self.wp5 = load_rows(os.path.join(gen, "wp5_campaign_summary.csv"))
+ self.wp6_schema = self.wp6[0]["schema_version"]
+ self.wp13_schema = self.wp13[0]["schema_version"]
+ self.wp5_schema = self.wp5[0]["schema_version"]
+ self.catalogs13 = []
+ for r in self.wp13:
+ if r["catalog"] not in self.catalogs13:
+ self.catalogs13.append(r["catalog"])
+ self.campaign_catalogs = []
+ for r in self.wp5:
+ if r["catalog"] not in self.campaign_catalogs:
+ self.campaign_catalogs.append(r["catalog"])
+
+ def wp6q(self, catalog, record_type, metric, n_kits=None, weighting=None,
+ cost_scenario=None):
+ out = []
+ for r in self.wp6:
+ if (r["catalog"] == catalog and r["record_type"] == record_type
+ and r["metric"] == metric
+ and (n_kits is None or r["n_kits"] == str(n_kits))
+ and (weighting is None or r["weighting"] == weighting)
+ and (cost_scenario is None
+ or r["cost_scenario"] == cost_scenario)):
+ out.append(r)
+ if len(out) != 1:
+ raise KeyError((catalog, record_type, metric, n_kits, weighting,
+ cost_scenario, "rows_found=%d" % len(out)))
+ return out[0]
+
+ def wp6_anchor(self, cost_scenario):
+ """currency_anchor_derived row (global; catalog is blank in the CSV)."""
+ return self.wp6q("", "currency_anchor_derived", "anchor_musd_per_cu",
+ cost_scenario=cost_scenario)
+
+ def wp6_baseline_n(self, catalog):
+ """Baseline kit count N, read from the cost_component rows' own
+ n_kits column (never hardcoded)."""
+ ns = {r["n_kits"] for r in self.wp6
+ if r["catalog"] == catalog and r["record_type"] == "cost_component"}
+ if len(ns) != 1:
+ raise KeyError(("wp6_baseline_n", catalog, ns))
+ return int(next(iter(ns)))
+
+ def wp13v(self, catalog, record_type, kit_option=None):
+ out = []
+ for r in self.wp13:
+ if (r["catalog"] == catalog and r["record_type"] == record_type
+ and (kit_option is None or r["kit_option"] == kit_option)):
+ out.append(r)
+ if len(out) != 1:
+ raise KeyError((catalog, record_type, kit_option,
+ "rows_found=%d" % len(out)))
+ return out[0]
+
+
+def short(catalog):
+ return catalog.split("/")[0].strip()
+
+
+# ---- row builder ------------------------------------------------------------
+def row(catalog="", record_type="", metric="", weighting="", cost_scenario="",
+ kit_option="", estimate="", p05="", p50="", p95="", value_lo="",
+ value_hi="", units="", notes=""):
+ return {
+ "schema_version": SCHEMA_VERSION, "catalog": catalog,
+ "record_type": record_type, "metric": metric, "weighting": weighting,
+ "cost_scenario": cost_scenario, "kit_option": kit_option,
+ "estimate": estimate, "p05": p05, "p50": p50, "p95": p95,
+ "value_lo": value_lo, "value_hi": value_hi, "units": units,
+ "notes": notes,
+ }
+
+
+def cu_to_musd(cu_row, anchor_row):
+ """(p05, p50, p95) in MUSD/removal, elementwise CU_percentile x
+ anchor_musd_per_cu -- the identical multiplication WP6/WP14 already
+ performs for the anchor catalog's own cost_per_removal_musd rows."""
+ a = float(anchor_row["estimate"])
+ return (float(cu_row["p05"]) * a, float(cu_row["p50"]) * a,
+ float(cu_row["p95"]) * a)
+
+
+def build_rows(d):
+ rows = []
+ anchors = {s: d.wp6_anchor(s) for s in SCENARIOS}
+ baseline_n = {}
+ cu_baseline = {}
+
+ for cat in (CAT_A, CAT_B):
+ baseline_n[cat] = d.wp6_baseline_n(cat)
+ n = baseline_n[cat]
+
+ # FoM under both weightings, p05/p50/p95 [kg/CU] -- machine-read.
+ for w in WEIGHTINGS:
+ fr = d.wp6q(cat, "fom", "fom", weighting=w)
+ bw = d.wp6q(cat, "fom", "band_weight", weighting=w)
+ rows.append(row(catalog=cat, record_type="fom", metric="fom",
+ weighting=w, estimate=fr["estimate"], p05=fr["p05"],
+ p50=fr["p50"], p95=fr["p95"], units=fr["units"],
+ notes=fr["notes"]))
+ rows.append(row(catalog=cat, record_type="fom", metric="band_weight",
+ weighting=w, estimate=bw["estimate"],
+ units=bw["units"], notes=bw["notes"]))
+
+ rmass = d.wp6q(cat, "fom", "removed_mass_per_mission")
+ rows.append(row(catalog=cat, record_type="fom",
+ metric="removed_mass_per_mission",
+ estimate=rmass["estimate"], units=rmass["units"],
+ notes=rmass["notes"]))
+
+ # Cost per removal, CU, at the baseline (Delta-v-limited) N.
+ cu = d.wp6q(cat, "amortization", "cost_per_removal", n_kits=n)
+ cu_baseline[cat] = cu
+ rows.append(row(catalog=cat, record_type="cost_per_removal_cu",
+ metric="cost_per_removal", p05=cu["p05"], p50=cu["p50"],
+ p95=cu["p95"], units=cu["units"],
+ notes="baseline N=%d (the Delta-v-limited amortization "
+ "minimum, generated/wp6_cost_summary.csv "
+ "record_type=amortization)" % n))
+
+ # Cost per removal, MUSD, per cost_scenario -- via the anchor rows.
+ for s in SCENARIOS:
+ p05, p50, p95 = cu_to_musd(cu, anchors[s])
+ if cat == CAT_A:
+ # CAT_A already has a dedicated WP6 cost_per_removal_musd row
+ # (the anchor was itself derived from CAT_A's own baseline
+ # cost). Cross-check rather than trust: this catches any
+ # future WP6 change silently drifting from this tool's own
+ # arithmetic.
+ wp6row = d.wp6q(cat, "cost_per_removal_musd", "cost_per_removal",
+ n_kits=n, cost_scenario=s)
+ for label, mine, theirs in (("p05", p05, wp6row["p05"]),
+ ("p50", p50, wp6row["p50"]),
+ ("p95", p95, wp6row["p95"])):
+ # Both operands are read back from 6-decimal CSV text, so
+ # each carries up to 5e-7 of independent rounding error;
+ # at the ~45 CU / ~0.06 MUSD/CU magnitudes here that
+ # compounds to ~3e-5 in the product -- a real drift bug
+ # would be orders of magnitude larger than this floor.
+ if abs(mine - float(theirs)) > 5e-5:
+ raise AssertionError(
+ "cost_per_removal_musd cross-check failed for "
+ "%s/%s/%s: computed %.6f != committed WP6 %.6f"
+ % (cat, s, label, mine, float(theirs)))
+ note = ("= cost_per_removal_CU(catalog=%s;baseline N=%d) x "
+ "anchor_musd_per_cu[%s]=%s MUSD/CU "
+ "(generated/wp6_cost_summary.csv "
+ "record_type=currency_anchor_derived); cross-checked "
+ "against that same file's own "
+ "record_type=cost_per_removal_musd row for this "
+ "catalog/scenario -- consistent to display precision "
+ "(this tool multiplies by the 6dp-rounded anchor while "
+ "the wp6 row uses the full-precision anchor, so the "
+ "last printed digit can differ by rounding path)"
+ % (cat, n, s, f6(anchors[s]["estimate"])))
+ else:
+ note = ("= cost_per_removal_CU(catalog=%s;baseline N=%d) x "
+ "anchor_musd_per_cu[%s]=%s MUSD/CU (generated/"
+ "wp6_cost_summary.csv record_type="
+ "currency_anchor_derived); NOTE: the anchor itself "
+ "was derived from catalog A's (%s) baseline campaign "
+ "cost (Sec.10 Method), not this catalog's own -- "
+ "applying it here assumes the CU->MUSD conversion "
+ "factor is campaign-structure-invariant across "
+ "catalogs rather than catalog-specific; a stated "
+ "simplifying assumption, not a hidden one"
+ % (cat, n, s, f6(anchors[s]["estimate"]), short(CAT_A)))
+ rows.append(row(catalog=cat, record_type="cost_per_removal_musd",
+ metric="cost_per_removal", cost_scenario=s,
+ estimate=f6(p50), p05=f6(p05), p50=f6(p50),
+ p95=f6(p95), units="MUSD_per_removal", notes=note))
+
+ # Envisat-class: no WP5/WP6 campaign rows -- say so explicitly.
+ rows.append(row(catalog=CAT_C, record_type="mission_class",
+ metric="not_kit_ranked",
+ notes="Envisat-class massive SSO payload has no WP5/WP6 "
+ "campaign rows (it is not part of the kit-installer "
+ "Monte Carlo sweep); FoM and cost_per_removal are "
+ "undefined for this class in this joined view. It "
+ "is evaluated instead as a separate controlled-"
+ "reentry mission class (spec WP13 Class C; see "
+ "generated/wp13_classC.md), not kit-ranked against "
+ "SL-16/SL-8."))
+
+ # Recommended kit + sail/EDT band, all three catalogs (wp13 only).
+ for cat in d.catalogs13:
+ rk = d.wp13v(cat, "recommended_kit")
+ rows.append(row(catalog=cat, record_type="recommended_kit",
+ kit_option=rk["kit_option"], notes=rk["notes"]))
+ sail = d.wp13v(cat, "sail_area_25yr")
+ rows.append(row(catalog=cat, record_type="sail_area_25yr",
+ value_lo=sail["value_lo"], value_hi=sail["value_hi"],
+ units=sail["units"], notes=sail["notes"]))
+ edt = d.wp13v(cat, "edt_years")
+ rows.append(row(catalog=cat, record_type="edt_years",
+ value_lo=edt["value_lo"], value_hi=edt["value_hi"],
+ units=edt["units"], notes=edt["notes"]))
+
+ # Priority rank -- the deliverable conclusion of this document, not a
+ # measured quantity; the supporting FoM/cost/citation rows above and the
+ # one-paragraph defenses in the .md are the evidence for it.
+ rows.append(row(catalog=CAT_A, record_type="priority_rank", metric="rank",
+ estimate="1",
+ notes="mass-dominant FoM under BOTH weightings + "
+ "McKnight top-20-all-SL-16 citation; see the .md "
+ "defense paragraph"))
+ rows.append(row(catalog=CAT_B, record_type="priority_rank", metric="rank",
+ estimate="2",
+ notes="cheap closing sail kit, high per-class population; "
+ "see the .md defense paragraph"))
+ rows.append(row(catalog=CAT_C, record_type="priority_rank", metric="rank",
+ kit_option="not-kit-ranked",
+ notes="separate controlled-reentry mission class (spec "
+ "WP13 Class C), not ranked against the two "
+ "kit-installer classes above; see the .md defense "
+ "paragraph"))
+
+ return rows, anchors, baseline_n, cu_baseline
+
+
+def write_csv(path, rows):
+ lines = [",".join(CSV_HEADER)]
+ for r in rows:
+ cells = []
+ for col in CSV_HEADER:
+ v = str(r[col])
+ if any(c in v for c in (",", "\"", "\n")):
+ v = "\"%s\"" % v.replace("\"", "\"\"")
+ cells.append(v)
+ lines.append(",".join(cells))
+ write_text(path, "\n".join(lines) + "\n")
+
+
+def build_md(d, anchors, baseline_n, cu_baseline):
+ L = []
+ w = L.append
+
+ fomA_sp = d.wp6q(CAT_A, "fom", "fom", weighting="spatial_density")
+ fomA_cr = d.wp6q(CAT_A, "fom", "fom", weighting="criticality")
+ fomB_sp = d.wp6q(CAT_B, "fom", "fom", weighting="spatial_density")
+ fomB_cr = d.wp6q(CAT_B, "fom", "fom", weighting="criticality")
+ bwA_sp = d.wp6q(CAT_A, "fom", "band_weight", weighting="spatial_density")
+ bwA_cr = d.wp6q(CAT_A, "fom", "band_weight", weighting="criticality")
+ bwB_sp = d.wp6q(CAT_B, "fom", "band_weight", weighting="spatial_density")
+ bwB_cr = d.wp6q(CAT_B, "fom", "band_weight", weighting="criticality")
+
+ w("# WP14 Prioritization: target-class ranking (cost + FoM + kit trade)")
+ w("")
+ w("A pure derived/joined view over three already-committed, already-")
+ w("regenerated CSVs -- `generated/wp6_cost_summary.csv` (schema %s), "
+ "`generated/wp13_kit_trade.csv` (schema %s), and "
+ "`generated/wp5_campaign_summary.csv` (schema %s). This tool runs no "
+ "new simulation and adds no new statistics; every number below is read "
+ "directly from those files, or is a single elementwise arithmetic "
+ "combination of two already-committed numbers (noted inline wherever "
+ "that occurs). Regenerated by "
+ "`tools/prioritization/make_prioritization.py` (Python 3 stdlib only, "
+ "R9)." % (d.wp6_schema, d.wp13_schema, d.wp5_schema))
+ w("")
+ w("## Ranking (spec:265-266)")
+ w("")
+ w("1. **%s** -- mass-dominant FoM under both weightings; the McKnight "
+ "top-of-ranking class." % CAT_A)
+ w("2. **%s** -- cheap closing sail kit, high per-class population."
+ % CAT_B)
+ w("3. **%s** -- a separate controlled-reentry mission class, "
+ "NOT kit-ranked against the two classes above." % CAT_C)
+ w("")
+ w("## Legal/consent gate (D12)")
+ w("")
+ w(D12_SENTENCE)
+ w("")
+
+ # ---- Catalog A -----------------------------------------------------
+ w("## 1. %s (priority 1)" % CAT_A)
+ w("")
+ w("### Figure of merit (debris-risk reduction per cost), p50 [kg/CU]")
+ w("")
+ w("| weighting | FoM p50 | FoM p05..p95 | band weight w(h) |")
+ w("|---|---:|---|---:|")
+ w("| spatial_density | %s | %s..%s | %s |" % (
+ f1(fomA_sp["p50"]), f1(fomA_sp["p05"]), f1(fomA_sp["p95"]),
+ f2(bwA_sp["estimate"])))
+ w("| criticality | %s | %s..%s | %s |" % (
+ f1(fomA_cr["p50"]), f1(fomA_cr["p05"]), f1(fomA_cr["p95"]),
+ f2(bwA_cr["estimate"])))
+ w("")
+ nA = baseline_n[CAT_A]
+ cuA = cu_baseline[CAT_A]
+ w("### Cost per removal (baseline N=%d, the Delta-v-limited amortization "
+ "minimum)" % nA)
+ w("")
+ w("- CU: p05 %s / p50 %s / p95 %s CU/removal (relative cost units, "
+ "primary metric)." % (f2(cuA["p05"]), f2(cuA["p50"]), f2(cuA["p95"])))
+ w("- MUSD (external, low-standing -- see the honesty framing quoted in "
+ "the WP14 evidence-pack cost section), p50 per cost_scenario:")
+ for s in SCENARIOS:
+ p05, p50, p95 = cu_to_musd(cuA, anchors[s])
+ w(" - %s: p05 %s / p50 %s / p95 %s MUSD/removal" % (
+ s, f3(p05), f3(p50), f3(p95)))
+ w("")
+ w("### Recommended kit")
+ w("")
+ rkA = d.wp13v(CAT_A, "recommended_kit")
+ sailA = d.wp13v(CAT_A, "sail_area_25yr")
+ edtA = d.wp13v(CAT_A, "edt_years")
+ w("- kit_option: `%s`" % rkA["kit_option"])
+ w("- sail area, 25-yr guideline (solar max..min): %s m^2 -- "
+ "impractical at the upper end." % band_str(sailA["value_lo"],
+ sailA["value_hi"], f0))
+ w("- EDT deorbit-time band: %s yr %s" % (
+ band_str(edtA["value_lo"], edtA["value_hi"], f1), edtA["notes"]))
+ w("- verbatim rationale, quoted from `generated/wp13_kit_trade.csv` (never "
+ "hand-transcribed): \"%s\"" % rkA["notes"])
+ w("")
+ w("### Prioritization defense (one paragraph)")
+ w("")
+ w("The %s class ranks first because its FoM dominates the %s class "
+ "under BOTH weightings (spatial-density %s vs %s kg/CU p50; "
+ "criticality %s vs %s kg/CU p50 -- computed from "
+ "generated/wp6_cost_summary.csv), which is the mass-dominance result "
+ "the WP6/WP14 cost model already establishes; that dominance is "
+ "corroborated, not merely asserted, by the published debris-ranking "
+ "literature: %s The class is the ADSC catalog-A anchor for exactly "
+ "this reason. Its recommended kit is an honest EDT candidate, not a "
+ "closed recommendation: sail-only does not close the 25-year "
+ "guideline for this class (%s m^2, impractical), so the "
+ "electrodynamic-tether branch is carried instead, and that branch "
+ "remains open on the libration/dynamic-tether-stability question "
+ "(T7, Pelaez et al. 2000, cited in wp13-literature.md) -- the EDT "
+ "years band above folds libration in only as a flat PLACEHOLDER "
+ "duty-cycle knob, never claimed solved. Legal accessibility is a "
+ "gate, not a factor in this ranking (D12, above)." % (
+ short(CAT_A), short(CAT_B), f1(fomA_sp["p50"]), f1(fomB_sp["p50"]),
+ f1(fomA_cr["p50"]), f1(fomB_cr["p50"]), MCKNIGHT_CITATION,
+ band_str(sailA["value_lo"], sailA["value_hi"], f0)))
+ w("")
+
+ # ---- Catalog B -----------------------------------------------------
+ w("## 2. %s (priority 2)" % CAT_B)
+ w("")
+ w("### Figure of merit (debris-risk reduction per cost), p50 [kg/CU]")
+ w("")
+ w("| weighting | FoM p50 | FoM p05..p95 | band weight w(h) |")
+ w("|---|---:|---|---:|")
+ w("| spatial_density | %s | %s..%s | %s |" % (
+ f1(fomB_sp["p50"]), f1(fomB_sp["p05"]), f1(fomB_sp["p95"]),
+ f2(bwB_sp["estimate"])))
+ w("| criticality | %s | %s..%s | %s |" % (
+ f1(fomB_cr["p50"]), f1(fomB_cr["p05"]), f1(fomB_cr["p95"]),
+ f2(bwB_cr["estimate"])))
+ w("")
+ nB = baseline_n[CAT_B]
+ cuB = cu_baseline[CAT_B]
+ w("### Cost per removal (baseline N=%d, the Delta-v-limited amortization "
+ "minimum)" % nB)
+ w("")
+ w("- CU: p05 %s / p50 %s / p95 %s CU/removal (relative cost units, "
+ "primary metric)." % (f2(cuB["p05"]), f2(cuB["p50"]), f2(cuB["p95"])))
+ w("- MUSD (external, low-standing; see the honesty framing quoted in "
+ "the WP14 evidence-pack cost section), p50 per cost_scenario -- note "
+ "this catalog reuses catalog A's currency anchor (see the CSV notes "
+ "column for the stated simplifying assumption):")
+ for s in SCENARIOS:
+ p05, p50, p95 = cu_to_musd(cuB, anchors[s])
+ w(" - %s: p05 %s / p50 %s / p95 %s MUSD/removal" % (
+ s, f3(p05), f3(p50), f3(p95)))
+ w("")
+ w("### Recommended kit")
+ w("")
+ rkB = d.wp13v(CAT_B, "recommended_kit")
+ sailB = d.wp13v(CAT_B, "sail_area_25yr")
+ w("- kit_option: `%s`" % rkB["kit_option"])
+ w("- sail area, 25-yr guideline (solar max..min): %s m^2 -- tens of "
+ "square meters, a practical kit; no EDT kit needed for this class."
+ % band_str(sailB["value_lo"], sailB["value_hi"], f0))
+ w("- verbatim rationale, quoted from `generated/wp13_kit_trade.csv` (never "
+ "hand-transcribed): \"%s\"" % rkB["notes"])
+ w("")
+ w("### Prioritization defense (one paragraph)")
+ w("")
+ w("The %s class ranks second: its FoM is smaller than %s's under both "
+ "weightings (mass-dominated, as above), but it closes today with a "
+ "cheap, practical sail kit (%s m^2 for the 25-year guideline, tens of "
+ "square meters -- no EDT branch and no open T7 caveat needed), and it "
+ "represents a much larger per-class population than the SL-16 top-20 "
+ "concern list: %s Cost per removal at its own baseline N is "
+ "essentially the same as %s's in CU terms (%s vs %s CU/removal p50), "
+ "so the batch-amortization economics are not a differentiator between "
+ "the two classes -- kit simplicity and closure certainty are. Legal "
+ "accessibility is a gate, not a factor in this ranking (D12, above)."
+ % (short(CAT_B), short(CAT_A),
+ band_str(sailB["value_lo"], sailB["value_hi"], f0),
+ SL8_POPULATION_CITATION, short(CAT_A), f2(cuB["p50"]),
+ f2(cuA["p50"])))
+ w("")
+
+ # ---- Catalog C (Envisat) -------------------------------------------
+ w("## 3. %s (separate mission class, not kit-ranked)" % CAT_C)
+ w("")
+ w("No WP5/WP6 campaign rows exist for this catalog (it is not part of "
+ "the kit-installer Monte Carlo sweep), so FoM and cost_per_removal "
+ "are undefined for it here -- stated rather than silently omitted.")
+ w("")
+ rkC = d.wp13v(CAT_C, "recommended_kit")
+ sailC = d.wp13v(CAT_C, "sail_area_25yr")
+ edtC = d.wp13v(CAT_C, "edt_years")
+ w("### Why it is not a kit choice")
+ w("")
+ w("- sail area, 25-yr guideline (solar max..min): %s m^2 (context "
+ "only -- not the recommendation)." % band_str(
+ sailC["value_lo"], sailC["value_hi"], f0))
+ w("- EDT deorbit-time band: %s yr (context only -- not the "
+ "recommendation)." % band_str(edtC["value_lo"], edtC["value_hi"], f1))
+ w("- kit_option: `%s`" % rkC["kit_option"])
+ w("- verbatim rationale, quoted from `generated/wp13_kit_trade.csv` (never "
+ "hand-transcribed): \"%s\"" % rkC["notes"])
+ w("")
+ w("### Prioritization defense (one paragraph)")
+ w("")
+ w("The %s is evaluated as a separate controlled-reentry mission class "
+ "(spec WP13 Class C), not ranked against %s or %s: %s Controlled-"
+ "deorbit delta-v, casualty-risk framing and cost detail for this "
+ "candidate live in `generated/wp13_classC.{csv,md}`, out of scope for "
+ "this cost/FoM joined view; what belongs here is the negative result "
+ "itself, stated plainly -- a sail or EDT kit-only deorbit is not the "
+ "recommended path for an 8,211 kg, ~98.4 deg payload, so it is not "
+ "assigned a kit-ranked priority number alongside the SL-16/SL-8 "
+ "classes above. Legal accessibility is a gate, not a factor here "
+ "either (D12, above)." % (short(CAT_C), short(CAT_A), short(CAT_B),
+ ENVISAT_CITATION))
+ w("")
+
+ w("## Honesty note (T5, criticality weighting citation basis)")
+ w("")
+ w(HONESTY_NOTE)
+ w("")
+ w("---")
+ w("*Generated by `tools/prioritization/make_prioritization.py` from "
+ "committed artifacts only. This is not legal advice.*")
+ w("")
+ return "\n".join(L)
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("usage: make_prioritization.py [out_dir]",
+ file=sys.stderr)
+ return 2
+ repo = sys.argv[1]
+ gen = os.path.join(repo, "generated")
+ out_dir = sys.argv[2] if len(sys.argv) > 2 else gen
+ os.makedirs(out_dir, exist_ok=True)
+
+ d = Data(gen)
+ rows, anchors, baseline_n, cu_baseline = build_rows(d)
+
+ csv_path = os.path.join(out_dir, "wp14_prioritization.csv")
+ md_path = os.path.join(out_dir, "wp14_prioritization.md")
+ write_csv(csv_path, rows)
+ md_text = build_md(d, anchors, baseline_n, cu_baseline)
+ md_text.encode("ascii") # hard guarantee: ASCII only
+ write_text(md_path, md_text)
+ print("[WP14] wrote wp14_prioritization.csv (%d rows) + "
+ "wp14_prioritization.md (%d lines)"
+ % (len(rows), md_text.count("\n") + 1))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/prioritization/test_prioritization.py b/tools/prioritization/test_prioritization.py
new file mode 100644
index 0000000..632f687
--- /dev/null
+++ b/tools/prioritization/test_prioritization.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""WP14 prioritization-table tests (registered with ctest, run by CMake's
+python3).
+
+ test_prioritization.py
+
+Checks (returns non-zero on any failure, R4), mirroring tools/viz/test_viz.py:
+ 1. Regenerating from the committed CSVs (wp6_cost_summary.csv,
+ wp13_kit_trade.csv, wp5_campaign_summary.csv) produces both expected
+ files and the generator itself exits 0 (its own internal cross-check
+ assertion between this tool's arithmetic and the committed WP6 anchor
+ row is part of that exit code).
+ 2. ASCII-only, LF-only, no stub markers (TODO/FIXME/XXX/TBD), no wall-clock
+ timestamp markers (R6), and no banned overclaim language.
+ 3. D10/R6 currency discipline: no point-dollar glyph anywhere, and every
+ currency-bearing row (MUSD units) is a genuine range (p05 < p95).
+ 4. Content: all three catalogs present (SL-16, SL-8, Envisat-class); the
+ Envisat-class row states it has no campaign rows; the D12 sentence,
+ the McKnight citation and the T5/honesty note are present in the .md.
+ 5. Determinism: two generator runs are byte-identical, and both match the
+ committed generated/wp14_prioritization.{csv,md} exactly (reproducibility
+ -- the same contract CI enforces with git diff).
+"""
+import csv
+import hashlib
+import os
+import re
+import subprocess
+import sys
+import tempfile
+
+STUB_MARKERS = ("TODO", "FIXME", "XXX", "TBD")
+TIMESTAMP_MARKERS = ("2026-", "2027-", "UTC", "GMT", "generated at", "generated on")
+BANNED_CLAIM_PATTERNS = (
+ (r"flight[\s-]*(ready|proven)", "flight-ready/-proven"),
+ (r"guarantee", "guarantee(d/s)"),
+ (r"approved", "approved"),
+ (r"licensed", "licensed"),
+ (r"compliant\b", "compliant"),
+)
+EXPECTED = ("wp14_prioritization.csv", "wp14_prioritization.md")
+
+fails = []
+
+
+def check(cond, msg):
+ if not cond:
+ fails.append(msg)
+
+
+def sha(path):
+ with open(path, "rb") as f:
+ return hashlib.sha256(f.read()).hexdigest()
+
+
+def load_csv(path):
+ with open(path, newline="", encoding="utf-8") as f:
+ return list(csv.DictReader(f))
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("usage: test_prioritization.py ", file=sys.stderr)
+ return 2
+ repo = os.path.abspath(sys.argv[1])
+ gen = os.path.join(repo, "generated")
+ make = os.path.join(repo, "tools", "prioritization", "make_prioritization.py")
+ committed = {name: os.path.join(gen, name) for name in EXPECTED}
+
+ for name, path in committed.items():
+ if not os.path.exists(path):
+ print("FAIL: committed generated/%s is missing" % name)
+ return 1
+
+ # 1. Regenerate into a scratch dir; the generator's own exit code carries
+ # its internal cross-check (this tool's own CU x anchor arithmetic vs
+ # the committed WP6 cost_per_removal_musd row for catalog A).
+ tmp = tempfile.mkdtemp(prefix="wp14_prioritization_")
+ r = subprocess.run([sys.executable, make, repo, tmp],
+ capture_output=True, text=True)
+ check(r.returncode == 0,
+ "make_prioritization failed: %s" % (r.stderr.strip() or r.stdout.strip()))
+
+ for name in EXPECTED:
+ path = os.path.join(tmp, name)
+ check(os.path.exists(path), "missing regenerated %s" % name)
+ if fails:
+ for f in fails:
+ print("FAIL:", f)
+ print("prioritization: %d failure(s)" % len(fails))
+ return 1
+
+ csv_txt = open(os.path.join(tmp, "wp14_prioritization.csv"), encoding="utf-8").read()
+ md_txt = open(os.path.join(tmp, "wp14_prioritization.md"), encoding="utf-8").read()
+
+ # 2. Hygiene: ASCII, LF-only, no stub markers / timestamps / banned claims.
+ for name, txt in (("wp14_prioritization.csv", csv_txt),
+ ("wp14_prioritization.md", md_txt)):
+ try:
+ txt.encode("ascii")
+ except UnicodeEncodeError:
+ check(False, "%s: non-ASCII content" % name)
+ with open(os.path.join(tmp, name), "rb") as f:
+ check(b"\r" not in f.read(), "%s: contains CR bytes (must be LF)" % name)
+ for m in STUB_MARKERS + TIMESTAMP_MARKERS:
+ check(m not in txt, "%s: contains forbidden marker '%s'" % (name, m))
+ low = txt.lower()
+ for pat, why in BANNED_CLAIM_PATTERNS:
+ check(re.search(pat, low) is None,
+ "%s: contains forbidden claim language: %s" % (name, why))
+ check(re.search(r"\bTRL\s*[56]\b", txt) is None,
+ "%s: claims TRL 5/6" % name)
+
+ # 3. D10/R6 currency discipline: never a point-dollar glyph; every
+ # currency-bearing (MUSD) row is a genuine range (p05 strictly < p95).
+ check("$" not in csv_txt, "csv: contains a point-dollar glyph")
+ check("$" not in md_txt, "md: contains a point-dollar glyph")
+ rows = load_csv(os.path.join(tmp, "wp14_prioritization.csv"))
+ musd_rows = [r for r in rows if "MUSD" in r["units"]]
+ check(len(musd_rows) > 0, "csv: no MUSD-denominated rows found at all")
+ for r in musd_rows:
+ p05, p95 = float(r["p05"]), float(r["p95"])
+ check(p05 < p95,
+ "csv: MUSD row (%s/%s/%s) is not a genuine range (p05 %.6f !< p95 %.6f)"
+ % (r["catalog"], r["record_type"], r["cost_scenario"], p05, p95))
+
+ # 4. Content checks.
+ catalogs = {r["catalog"] for r in rows if r["catalog"]}
+ for cat in ("SL-16 / Zenit-2 second stage", "SL-8 / Kosmos-3M second stage",
+ "Envisat-class massive SSO payload"):
+ check(cat in catalogs, "csv: missing catalog %r" % cat)
+ check(any(r["record_type"] == "mission_class"
+ and r["catalog"] == "Envisat-class massive SSO payload"
+ for r in rows),
+ "csv: Envisat-class row does not state it has no campaign rows")
+ check("no wp5/wp6 campaign rows" in md_txt.lower(),
+ "md: Envisat-class section does not state it has no campaign rows")
+ check("D12" in md_txt, "md: missing the D12 legal-gate reference")
+ check("gate and metadata flags, never a multiplier" in md_txt,
+ "md: missing the D12 gate-not-multiplier sentence")
+ check("McKnight" in md_txt, "md: missing the McKnight citation")
+ check("doi:10.1016/j.actaastro.2021.01.021" in md_txt,
+ "md: McKnight citation lacks its DOI")
+ check("Honesty note" in md_txt, "md: missing the honesty-note section")
+ check("T5" in md_txt, "md: missing a T5 reference in the honesty note")
+ check("PLACEHOLDER" in md_txt,
+ "md: honesty note must acknowledge the non-peak weight table is "
+ "still PLACEHOLDER")
+
+ # every non-mission_class/priority_rank row must carry a source-bearing
+ # notes field (D10-style discipline: never a bare, unexplained number).
+ for r in rows:
+ if r["record_type"] in ("fom", "cost_per_removal_cu",
+ "cost_per_removal_musd", "recommended_kit",
+ "sail_area_25yr", "edt_years"):
+ check(r["notes"].strip() != "",
+ "csv row %s/%s/%s has an empty notes field"
+ % (r["catalog"], r["record_type"], r.get("weighting", "")))
+
+ # 5. Determinism + committed == regenerated.
+ tmp2 = tempfile.mkdtemp(prefix="wp14_prioritization_2_")
+ r2 = subprocess.run([sys.executable, make, repo, tmp2],
+ capture_output=True, text=True)
+ check(r2.returncode == 0, "second make_prioritization run failed")
+ if r2.returncode == 0:
+ for name in EXPECTED:
+ h1 = sha(os.path.join(tmp, name))
+ h2 = sha(os.path.join(tmp2, name))
+ check(h1 == h2, "%s: generator is not deterministic across runs" % name)
+
+ for name, cpath in committed.items():
+ tpath = os.path.join(tmp, name)
+ if not os.path.exists(tpath):
+ continue
+ with open(cpath, "rb") as a, open(tpath, "rb") as b:
+ cb, tb = a.read(), b.read()
+ # normalize CRLF->LF so a Windows checkout of the committed file still
+ # matches the LF the generator writes (CI enforces raw bytes via git).
+ check(cb.replace(b"\r\n", b"\n") == tb,
+ "%s: committed bytes != regenerated (reproducibility)" % name)
+
+ if fails:
+ for f in fails:
+ print("FAIL:", f)
+ print("prioritization: %d failure(s)" % len(fails))
+ return 1
+ print("prioritization: all checks passed (%d artifacts, %d rows)"
+ % (len(EXPECTED), len(rows)))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/readme/fill_readme_numbers.py b/tools/readme/fill_readme_numbers.py
new file mode 100644
index 0000000..74af405
--- /dev/null
+++ b/tools/readme/fill_readme_numbers.py
@@ -0,0 +1,462 @@
+#!/usr/bin/env python3
+"""README marker-block filler (WP14 review item 0.4b / C4).
+
+ fill_readme_numbers.py [--check]
+
+Regenerates ONLY the text strictly between the two committed marker-comment
+pairs in README.md --
+
+
+ ...
+
+
+
+ ...
+
+
+-- from the two committed CSVs (generated/wp5_campaign_summary.csv,
+generated/wp6_cost_summary.csv). Every NUMBER quoted inside a block (table
+cell or inline prose) is read mechanically from those CSVs with a fixed
+rounding; the surrounding prose/table skeleton is literal template text (it
+does not reflow). This closes the review's C4 finding: before this tool,
+README.md was hand-updated and could silently drift from the committed CSVs
+it claims to summarize.
+
+Default mode rewrites README.md in place. `--check` instead exits non-zero
+if the regenerated blocks differ from the committed file (for CI: a stale
+README fails the build instead of silently drifting -- see ci.yml).
+
+Python 3 standard library only (spec v4.2 R9). This tool's own SOURCE stays
+pure ASCII (spec R11): the README's existing Unicode punctuation/math glyphs
+(em dash, Delta, times, Sigma, subscript i, ...) are produced via explicit
+\\uXXXX escapes in ordinary string literals, never as literal source bytes.
+No wall-clock timestamps; output is UTF-8 with "\\n" line endings so the CI
+byte-reproducibility gate can compare it (matching every other generator in
+this repository).
+"""
+import csv
+import os
+import re
+import sys
+
+# ---- Unicode glyphs used inside the README blocks, named so the source
+# ---- file itself stays ASCII-only (spec R11) while the generated text
+# ---- is not -- each is an explicit \uXXXX escape, never a literal byte.
+EM = "\u2014" # em dash
+MINUS = "\u2212" # minus sign (distinct from ASCII hyphen)
+DELTA = "\u0394" # Greek capital delta (used for "Dv")
+GE = "\u2265" # >=
+TIMES = "\u00d7" # multiplication sign
+SUBI = "\u1d62" # Latin subscript small letter i
+SIGMA = "\u03a3" # Greek capital sigma
+DOT = "\u00b7" # middle dot
+PM = "\u00b1" # plus-minus sign
+GG = "\u226b" # much-greater-than
+
+WP5_START = ("")
+WP5_END = ""
+WP6_START = ""
+WP6_END = ""
+
+# Fixed catalog display labels (literal template text, D2/D13: catalog
+# identity/altitude is out of WP14's scope -- see the WP14 integration map's
+# trap list on CATALOG_ALT_KM-style hardcoded mirrors).
+CATALOG_SHORT = {
+ "SL-16 / Zenit-2 second stage": "SL-16",
+ "SL-8 / Kosmos-3M second stage": "SL-8",
+}
+CATALOG_FOM_LABEL = {
+ "SL-16 / Zenit-2 second stage": "SL-16 (~9 t, 840 km)",
+ "SL-8 / Kosmos-3M second stage": "SL-8 (~1.4 t, 750 km)",
+}
+
+
+def f0(v):
+ return "%.0f" % float(v)
+
+def f1(v):
+ return "%.1f" % float(v)
+
+def f2(v):
+ return "%.2f" % float(v)
+
+def f3(v):
+ return "%.3f" % float(v)
+
+def f4(v):
+ return "%.4f" % float(v)
+
+
+def load_csv(path):
+ with open(path, newline="", encoding="utf-8") as f:
+ return list(csv.DictReader(f))
+
+
+class Wp5Data:
+ """generated/wp5_campaign_summary.csv (schema 1.1, WP5's own additive
+ dispersion_set_id/worst_abort_clearance_m bump -- unrelated to WP14 and
+ unread here)."""
+
+ def __init__(self, root):
+ self.rows = load_csv(os.path.join(root, "generated",
+ "wp5_campaign_summary.csv"))
+ self.catalogs = []
+ for r in self.rows:
+ if r["catalog"] not in self.catalogs:
+ self.catalogs.append(r["catalog"])
+ self.n_runs = int(float(self.rows[0]["n_runs"]))
+
+ def m(self, catalog, metric):
+ for r in self.rows:
+ if r["catalog"] == catalog and r["metric"] == metric:
+ return r
+ raise KeyError((catalog, metric))
+
+
+class Wp6Data:
+ """generated/wp6_cost_summary.csv (schema 1.1, WP14)."""
+
+ def __init__(self, root):
+ self.rows = load_csv(os.path.join(root, "generated",
+ "wp6_cost_summary.csv"))
+
+ def q(self, catalog, record_type, metric, n_kits=None, weighting=None):
+ for r in self.rows:
+ if (r["catalog"] == catalog and r["record_type"] == record_type
+ and r["metric"] == metric
+ and (n_kits is None or r["n_kits"] == str(n_kits))
+ and (weighting is None or r["weighting"] == weighting)):
+ return r
+ raise KeyError((catalog, record_type, metric, n_kits, weighting))
+
+ def first_row(self, catalog, record_type):
+ for r in self.rows:
+ if r["catalog"] == catalog and r["record_type"] == record_type:
+ return r
+ raise KeyError((catalog, record_type))
+
+ def rows_of(self, catalog, record_type, metric):
+ return [r for r in self.rows
+ if r["catalog"] == catalog and r["record_type"] == record_type
+ and r["metric"] == metric]
+
+
+def rate_cell(row):
+ """'**0.556** [0.512, 0.599]' from a WP5 rate row (estimate/wilson_low/
+ wilson_high), 3dp."""
+ return "**%s** [%s, %s]" % (f3(row["estimate"]), f3(row["wilson_low"]),
+ f3(row["wilson_high"]))
+
+
+def ko_cell(row):
+ """rate_cell() plus the '[L0, ds-v1]' fidelity/dispersion tag, both
+ parsed out of the row's own notes field (never hand-typed) so a future
+ dispersion-set or fidelity-level change is reflected automatically."""
+ m = re.search(r"level tag (\S+), dispersion set (\S+)", row["notes"])
+ return "%s [%s, %s]" % (rate_cell(row), m.group(1), m.group(2))
+
+
+def pctl(row, fmt):
+ return "%s / %s / %s" % (fmt(row["p05"]), fmt(row["p50"]), fmt(row["p95"]))
+
+
+def count(row):
+ return int(round(float(row["estimate"])))
+
+
+def build_wp5_block(d5):
+ A, B = d5.catalogs[0], d5.catalogs[1]
+ n_runs = d5.n_runs
+
+ succA, succB = d5.m(A, "success_rate"), d5.m(B, "success_rate")
+ nptA, nptB = (d5.m(A, "nonproductive_termination_rate"),
+ d5.m(B, "nonproductive_termination_rate"))
+ gaA, gaB = d5.m(A, "gate_abort_run_rate"), d5.m(B, "gate_abort_run_rate")
+ koA, koB = (d5.m(A, "keep_out_violation_rate"),
+ d5.m(B, "keep_out_violation_rate"))
+ dvA, dvB = d5.m(A, "dv_used_m_per_s"), d5.m(B, "dv_used_m_per_s")
+ kuA, kuB = d5.m(A, "kits_used"), d5.m(B, "kits_used")
+ rmA, rmB = (d5.m(A, "removals_per_mission"),
+ d5.m(B, "removals_per_mission"))
+ syA, syB = d5.m(A, "sync_arrival_time_s"), d5.m(B, "sync_arrival_time_s")
+
+ dvexA, dvexB = count(d5.m(A, "dv_exhausted")), count(d5.m(B, "dv_exhausted"))
+ kitexA, kitexB = (count(d5.m(A, "kit_exhausted")),
+ count(d5.m(B, "kit_exhausted")))
+ koCountA, koCountB = (count(d5.m(A, "keep_out_violation")),
+ count(d5.m(B, "keep_out_violation")))
+ compA, compB = count(d5.m(A, "completed")), count(d5.m(B, "completed"))
+ gaCountA, gaCountB = (count(d5.m(A, "gate_abort")),
+ count(d5.m(B, "gate_abort")))
+ stCountA, stCountB = (count(d5.m(A, "sync_timeout")),
+ count(d5.m(B, "sync_timeout")))
+
+ m = re.search(r"level tag (\S+), dispersion set (\S+)", koA["notes"])
+ l0tag, dstag = m.group(1), m.group(2)
+ dv_limited_pct = int(round(100.0 * dvexA / n_runs))
+
+ L = []
+ L.append("The full N = %d campaign (both catalog presets) runs in a few "
+ "tens of seconds" % n_runs)
+ L.append("on a CI runner (see the Actions log of the current run for the "
+ "actual figure).")
+ L.append("`success` here means a **productive end** %s the mission "
+ "installed its" % EM)
+ L.append("full 4-kit complement (`kit_exhausted`) or cleared all 6 "
+ "targets (`completed`) %s" % EM)
+ L.append("not one cut short by %sv exhaustion or a keep-out violation."
+ % DELTA)
+ L.append("")
+ L.append("| metric | SL-16 / Zenit-2 class | SL-8 / Kosmos-3M class |")
+ L.append("|---|---|---|")
+ L.append("| success rate | %s | %s |" % (rate_cell(succA), rate_cell(succB)))
+ L.append("| nonproductive-termination rate (= 1 %s success) | %s | %s |"
+ % (MINUS, rate_cell(nptA), rate_cell(nptB)))
+ L.append("| gate-abort-run rate (abort-path exposure) | %s | %s |"
+ % (rate_cell(gaA), rate_cell(gaB)))
+ L.append("| keep-out-violation rate | %s | %s |"
+ % (ko_cell(koA), ko_cell(koB)))
+ L.append("| %sv used p05/p50/p95 [m/s] | %s | %s |"
+ % (DELTA, pctl(dvA, f0), pctl(dvB, f0)))
+ L.append("| kits used p05/p50/p95 | %s | %s |"
+ % (pctl(kuA, f0), pctl(kuB, f0)))
+ L.append("| removals/mission p05/p50/p95 | %s | %s |"
+ % (pctl(rmA, f0), pctl(rmB, f0)))
+ L.append("| sync arrival p05/p50/p95 [s] | %s | %s |"
+ % (pctl(syA, f2), pctl(syB, f2)))
+ L.append("| failure counts (runs) | dv_exhausted %d, kit_exhausted %d, "
+ "keep_out %d, completed %d | dv_exhausted %d, kit_exhausted "
+ "%d, keep_out %d, completed %d |"
+ % (dvexA, kitexA, koCountA, compA, dvexB, kitexB, koCountB, compB))
+ L.append("| per-target events | gate_abort %d, sync_timeout %d | "
+ "gate_abort %d, sync_timeout %d |"
+ % (gaCountA, stCountA, gaCountB, stCountB))
+ L.append("")
+ L.append("Two abort-related rates are reported and are deliberately "
+ "distinct:")
+ L.append("**`gate-abort-run rate`** is the abort-path exposure (fraction "
+ "of runs with %s 1" % GE)
+ L.append("closing-speed gate abort %s what the spec calls the \"abort "
+ "rate\"), while" % EM)
+ L.append("**`nonproductive-termination rate`** is 1 %s success. Under "
+ "the current *flat" % MINUS)
+ L.append("PLACEHOLDER* %sv cost the two coincide numerically %s every "
+ "aborting mission needs" % (DELTA, EM))
+ L.append("an extra target-slot to still install its kits and so "
+ "exhausts the 140 m/s")
+ L.append("budget %s but they are separate concepts and will diverge "
+ "once the cost model" % EM)
+ L.append("gains structure. The honest campaign finding: the servicer is "
+ "**%sv-limited about" % DELTA)
+ L.append("%d%% of the time** (`dv_exhausted` = %d/%d) and installs its "
+ "full kit" % (dv_limited_pct, dvexA, n_runs))
+ L.append("complement the rest; the attitude sync **never** times out "
+ "across the sampled")
+ L.append("tumble/attitude/actuator dispersions, and keep-out violations "
+ "are **%d of %d**" % (koCountA, n_runs))
+ L.append("per catalog [%s: linear CW, dispersion set %s, Wilson 95%% "
+ "upper bound" % (l0tag, dstag))
+ L.append("%s] %s the WP11 clearing-abort law accepts an abort only when "
+ "the analytic" % (f4(koA["wilson_high"]), EM))
+ L.append("post-burn ellipse clears keep-out plus a design margin "
+ "(mechanism forensics:")
+ L.append("`generated/wp10_violation_forensics.md`; audit:")
+ L.append("`generated/wp11_abort_audit.md`). `completed` (all 6 targets) "
+ "is 0 by")
+ L.append("construction %s 4 kits cannot service 6 targets. The `%sv "
+ "used` / `kits used`" % (EM, DELTA))
+ L.append("percentiles matching across the two presets is expected (a "
+ "flat PLACEHOLDER cost")
+ L.append("takes quantized, catalog-independent values %s not a "
+ "copy-paste bug). Full per-run" % EM)
+ L.append("records and the column schema are in "
+ "[generated/](generated/).")
+ return "\n".join(L)
+
+
+def build_wp6_block(d5, d6):
+ A, B = d5.catalogs[0], d5.catalogs[1]
+ Ashort, Bshort = CATALOG_SHORT[A], CATALOG_SHORT[B]
+ n_runs = d5.n_runs
+
+ ns = sorted({int(r["n_kits"]) for r in d6.rows
+ if r["record_type"] == "amortization"
+ and r["metric"] == "cost_per_removal"})
+ cprA = {n: float(d6.q(A, "amortization", "cost_per_removal", n)["p50"])
+ for n in ns}
+ cprB = {n: float(d6.q(B, "amortization", "cost_per_removal", n)["p50"])
+ for n in ns}
+ nmin = min(ns, key=lambda n: cprA[n])
+ nmax = max(ns)
+ # Curated display subset (first 3 points, the curve minimum, one past
+ # it, and the sweep max) -- matches the committed table exactly and
+ # keeps it short; the full sweep is in the committed CSV.
+ display_ns = sorted(set(n for n in (1, 2, 3, nmin, nmin + 1, nmax)
+ if n in ns))
+
+ baseline_n = int(d6.first_row(A, "cost_component")["n_kits"])
+
+ ratio_min = float(d6.q(A, "amortization", "cost_per_removal_ratio_to_n1",
+ nmin)["p50"])
+ pct_min = ratio_min * 100.0
+ saving_x = 1.0 / ratio_min
+ catalog_gap_pct = abs(cprA[nmin] - cprB[nmin]) / cprA[nmin] * 100.0
+
+ fomA_sp = d6.q(A, "fom", "fom", weighting="spatial_density")
+ fomA_cr = d6.q(A, "fom", "fom", weighting="criticality")
+ fomB_sp = d6.q(B, "fom", "fom", weighting="spatial_density")
+ fomB_cr = d6.q(B, "fom", "fom", weighting="criticality")
+ wA_sp = d6.q(A, "fom", "band_weight", weighting="spatial_density")
+ wA_cr = d6.q(A, "fom", "band_weight", weighting="criticality")
+ wB_sp = d6.q(B, "fom", "band_weight", weighting="spatial_density")
+ wB_cr = d6.q(B, "fom", "band_weight", weighting="criticality")
+
+ tornado = [r for r in d6.rows if r["catalog"] == A
+ and r["record_type"] == "tornado"
+ and r["metric"] == "cost_per_removal_swing"]
+ if len(tornado) != 5:
+ raise RuntimeError(
+ "expected exactly 5 tornado cost_per_removal_swing rows for %r, "
+ "got %d -- the WP6-NUMBERS tornado prose template is hardcoded "
+ "for a 5-parameter, 2-line wrap and needs a human rewrite if the "
+ "parameter count changes" % (A, len(tornado)))
+ items = [(r["param"], float(r["p50"])) for r in tornado]
+
+ L = []
+ L.append("The full sweep (%d runs %s 2 catalogs %s N = %d..%d kits) runs "
+ "in a few tens of" % (n_runs, TIMES, TIMES, ns[0], nmax))
+ L.append("seconds on a CI runner (see the Actions log of the current run "
+ "for the actual")
+ L.append("figure), and the baseline (N = %d) removals reproduce the "
+ "committed WP5 CSV" % baseline_n)
+ L.append("exactly (`MATCH (schema 1.0 consumed)`).")
+ L.append("")
+ L.append("**Amortization curve %s cost/removal vs kits carried N (%s "
+ "class):**" % (EM, Ashort))
+ L.append("")
+ L.append("| N | cost/removal p50 [CU] | ratio to N=1 | removals p50 |")
+ L.append("|---:|---:|---:|---:|")
+ for n in display_ns:
+ cpr = float(d6.q(A, "amortization", "cost_per_removal", n)["p50"])
+ ratio = float(d6.q(A, "amortization", "cost_per_removal_ratio_to_n1",
+ n)["p50"])
+ rem = float(d6.q(A, "amortization", "removals", n)["p50"])
+ if n == nmin:
+ L.append("| **%d** | **%s** | **%s** | %s |"
+ % (n, f2(cpr), f3(ratio), f0(rem)))
+ else:
+ L.append("| %d | %s | %s | %s |" % (n, f2(cpr), f3(ratio), f0(rem)))
+ L.append("")
+ L.append("Batch amortization drives cost/removal down to **%s %% of the "
+ "single-target" % f1(pct_min))
+ L.append("(N = %d) baseline** %s a **%s%s per-removal saving** %s "
+ "bottoming at N = %d where the"
+ % (ns[0], EM, f1(saving_x), TIMES, EM, nmin))
+ L.append("**%sv budget (not the kit count)** caps removals at %d; "
+ "carrying more kits then" % (DELTA, nmin))
+ L.append("adds cost without removals and the curve turns back up. That "
+ "shape *is* the")
+ L.append("quantitative installer/batch argument (%s class is within "
+ "~%s %%)." % (Bshort, f1(catalog_gap_pct)))
+ L.append("")
+ L.append("**FoM = %s m%s%sw(h%s)/C_campaign at N = %d (p50, kg/CU):**"
+ % (SIGMA, SUBI, DOT, SUBI, baseline_n))
+ L.append("")
+ L.append("| catalog | spatial-density | criticality |")
+ L.append("|---|---:|---:|")
+ L.append("| %s | %s (w = %s) | %s (w = %s) |"
+ % (CATALOG_FOM_LABEL[A], f2(fomA_sp["p50"]), f2(wA_sp["estimate"]),
+ f2(fomA_cr["p50"]), f2(wA_cr["estimate"])))
+ L.append("| %s | %s (w = %s) | %s (w = %s) |"
+ % (CATALOG_FOM_LABEL[B], f2(fomB_sp["p50"]), f2(wB_sp["estimate"]),
+ f2(fomB_cr["p50"]), f2(wB_cr["estimate"])))
+ L.append("")
+ L.append("The ~9 t class outranks the ~1.4 t class under **both** "
+ "weightings (FoM is")
+ L.append("mass-dominated), but the **band weight flips** %s spatial "
+ "density values the" % EM)
+ L.append("750 km band more, the criticality index values the 840 km "
+ "band more %s a genuine" % EM)
+ L.append("metric-choice disagreement tracked as **open trade T5**. "
+ "**Tornado** (%s," % Ashort)
+ L.append("%s30 %%, ranked by cost/removal swing): `%s` (%s CU) %s `%s` "
+ "(%s) >"
+ % (PM, items[0][0], f1(items[0][1]), GG, items[1][0],
+ f1(items[1][1])))
+ L.append("`%s` (%s) > `%s` (%s) > `%s` (%s). All"
+ % (items[2][0], f1(items[2][1]), items[3][0], f1(items[3][1]),
+ items[4][0], f1(items[4][1])))
+ L.append("values are relative CU; **no absolute-dollar figure is "
+ "claimed**.")
+ return "\n".join(L)
+
+
+def replace_block(text, start_marker, end_marker, new_body):
+ start_idx = text.index(start_marker)
+ end_idx = text.index(end_marker, start_idx)
+ before = text[:start_idx + len(start_marker)]
+ after = text[end_idx:]
+ return before + "\n" + new_body + "\n" + after
+
+
+def rewrite(text, d5, d6):
+ text = replace_block(text, WP5_START, WP5_END, build_wp5_block(d5))
+ text = replace_block(text, WP6_START, WP6_END, build_wp6_block(d5, d6))
+ return text
+
+
+def _first_diff_line(a, b):
+ la, lb = a.split("\n"), b.split("\n")
+ for i, (x, y) in enumerate(zip(la, lb)):
+ if x != y:
+ return i + 1, x, y
+ if len(la) != len(lb):
+ return min(len(la), len(lb)) + 1, "", ""
+ return None
+
+
+def main():
+ argv = sys.argv[1:]
+ check_mode = "--check" in argv
+ positional = [a for a in argv if a != "--check"]
+ if not positional:
+ print("usage: fill_readme_numbers.py [--check]",
+ file=sys.stderr)
+ return 2
+ root = os.path.abspath(positional[0])
+ readme_path = os.path.join(root, "README.md")
+
+ with open(readme_path, encoding="utf-8") as f:
+ original = f.read()
+
+ d5 = Wp5Data(root)
+ d6 = Wp6Data(root)
+ new_text = rewrite(original, d5, d6)
+
+ if check_mode:
+ if new_text == original:
+ print("[readme_numbers] README.md marker blocks match the "
+ "committed CSVs")
+ return 0
+ print("[readme_numbers] README.md marker blocks are STALE vs the "
+ "committed CSVs", file=sys.stderr)
+ diff = _first_diff_line(original, new_text)
+ if diff:
+ lineno, old, new = diff
+ print(" first difference at line %d:" % lineno, file=sys.stderr)
+ print(" committed : %r" % old, file=sys.stderr)
+ print(" from CSVs : %r" % new, file=sys.stderr)
+ return 1
+
+ with open(readme_path, "w", encoding="utf-8", newline="\n") as f:
+ f.write(new_text)
+ if new_text == original:
+ print("[readme_numbers] README.md marker blocks already up to date")
+ else:
+ print("[readme_numbers] README.md marker blocks rewritten")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/readme/test_readme_numbers.py b/tools/readme/test_readme_numbers.py
new file mode 100644
index 0000000..7c05431
--- /dev/null
+++ b/tools/readme/test_readme_numbers.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""README marker-block filler test (registered with ctest as `readme_numbers`).
+
+ test_readme_numbers.py
+
+Checks (non-zero exit on any failure, R4):
+ 1. --check mode passes against the real, committed README.md -- i.e. the
+ WP5-NUMBERS/WP6-NUMBERS marker blocks already reproduce byte-for-byte
+ from the committed CSVs (closes review finding C4: README cannot
+ silently drift from generated/wp5_campaign_summary.csv /
+ generated/wp6_cost_summary.csv without this test catching it).
+ 2. --check mode FAILS when a number inside a marker block is corrupted in
+ a scratch copy -- proves the check reads block content, not just
+ marker-comment presence.
+ 3. Determinism: default (rewrite) mode is idempotent on an already-correct
+ scratch copy (content-equal, ignoring the CRLF/LF checkout-dependent
+ line-ending representation the same way test_evidence.py does), and two
+ independent regenerations from the same inputs are byte-identical.
+"""
+import hashlib
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+fails = []
+def check(cond, msg):
+ if not cond:
+ fails.append(msg)
+
+
+def sha(path):
+ with open(path, "rb") as f:
+ return hashlib.sha256(f.read()).hexdigest()
+
+
+def make_scratch_repo(repo):
+ """Copy just the inputs fill_readme_numbers.py needs (README.md plus the
+ two committed CSVs it reads) into a throwaway directory, so the
+ corruption test can mutate a copy without touching the real working
+ tree."""
+ tmp = tempfile.mkdtemp(prefix="wp14_readme_")
+ os.makedirs(os.path.join(tmp, "generated"))
+ shutil.copy(os.path.join(repo, "README.md"), os.path.join(tmp, "README.md"))
+ for name in ("wp5_campaign_summary.csv", "wp6_cost_summary.csv"):
+ shutil.copy(os.path.join(repo, "generated", name),
+ os.path.join(tmp, "generated", name))
+ return tmp
+
+
+def run_tool(root, *args):
+ return subprocess.run(
+ [sys.executable, os.path.join(HERE, "fill_readme_numbers.py"), root]
+ + list(args),
+ capture_output=True, text=True)
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("usage: test_readme_numbers.py ", file=sys.stderr)
+ return 2
+ repo = os.path.abspath(sys.argv[1])
+
+ # 1. --check against the real, committed README.md must pass.
+ r = run_tool(repo, "--check")
+ check(r.returncode == 0,
+ "fill_readme_numbers.py --check failed against the committed "
+ "README: %s" % (r.stdout + r.stderr).strip())
+
+ # 2. Corrupt a pinned number inside the WP6 marker block in a scratch
+ # copy; --check must now fail.
+ tmp1 = make_scratch_repo(repo)
+ try:
+ readme_path = os.path.join(tmp1, "README.md")
+ with open(readme_path, encoding="utf-8") as f:
+ text = f.read()
+ check("44.80" in text,
+ "fixture assumption stale: pinned number '44.80' not found "
+ "in README.md")
+ corrupted = text.replace("44.80", "99.99", 1)
+ check(corrupted != text, "corruption was a no-op -- fixture stale")
+ with open(readme_path, "w", encoding="utf-8", newline="\n") as f:
+ f.write(corrupted)
+ r2 = run_tool(tmp1, "--check")
+ check(r2.returncode != 0,
+ "--check did not catch a corrupted number in a marker block")
+ finally:
+ shutil.rmtree(tmp1, ignore_errors=True)
+
+ # 3. Determinism: rewrite-mode is idempotent on an already-correct copy
+ # (content-equal; the local checkout's CRLF vs the tool's canonical
+ # LF output are not a real difference -- read both in text mode,
+ # which normalizes line endings, exactly like test_evidence.py's
+ # committed-vs-regenerated comparison), and two independent
+ # regenerations of the same inputs are byte-identical.
+ tmp2 = make_scratch_repo(repo)
+ try:
+ readme_path = os.path.join(tmp2, "README.md")
+ with open(readme_path, encoding="utf-8") as f:
+ before_text = f.read()
+ r3 = run_tool(tmp2)
+ check(r3.returncode == 0,
+ "default-mode run failed: %s" % (r3.stdout + r3.stderr).strip())
+ with open(readme_path, encoding="utf-8") as f:
+ after_text = f.read()
+ check(before_text == after_text,
+ "rewriting an already-correct README changed its content "
+ "(not idempotent)")
+ after_sha = sha(readme_path)
+ r4 = run_tool(tmp2)
+ check(r4.returncode == 0, "second default-mode run failed: %s"
+ % (r4.stdout + r4.stderr).strip())
+ after2_sha = sha(readme_path)
+ check(after_sha == after2_sha,
+ "two regenerations from the same inputs are not "
+ "byte-identical (nondeterministic)")
+ finally:
+ shutil.rmtree(tmp2, ignore_errors=True)
+
+ if fails:
+ for m in fails:
+ print("FAIL:", m)
+ print("readme_numbers: %d failure(s)" % len(fails))
+ return 1
+ print("readme_numbers: all checks passed")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/regenerate_all.sh b/tools/regenerate_all.sh
index 357367e..25ab953 100644
--- a/tools/regenerate_all.sh
+++ b/tools/regenerate_all.sh
@@ -13,27 +13,29 @@ set -euo pipefail
cd "$(dirname "$0")/.." # repo root
BUILD="${1:-build}"
-echo "[1/11] WP5 campaign Monte Carlo (N=500 x 2 catalogs)"
+echo "[1/12] WP5 campaign Monte Carlo (N=500 x 2 catalogs)"
"$BUILD/adsc_campaign" 500 generated
-echo "[2/11] WP12 fidelity ladder (L0/L1/L2 re-verification of the WP5 abort events)"
+echo "[2/12] WP12 fidelity ladder (L0/L1/L2 re-verification of the WP5 abort events)"
"$BUILD/adsc_ladder" 500 generated
-echo "[3/11] WP6 cost model + FoM (kit sweep, consumes WP5)"
+echo "[3/12] WP6 cost model + FoM (kit sweep, consumes WP5)"
"$BUILD/adsc_cost" 500 generated
-echo "[4/11] WP3 decay-trade CSV"
+echo "[4/12] WP3 decay-trade CSV"
"$BUILD/decay_trade" generated
-echo "[5/11] WP13 kit-class trade + class-C controlled-reentry comparison"
+echo "[5/12] WP13 kit-class trade + class-C controlled-reentry comparison"
"$BUILD/kit_trade" generated
-echo "[6/11] T6 small-debris flux sweep"
+echo "[6/12] WP14 prioritization table (joined view of WP6 cost + WP13 kit trade)"
+python3 tools/prioritization/make_prioritization.py . generated
+echo "[7/12] T6 small-debris flux sweep"
"$BUILD/flux_sweep" generated
-echo "[7/11] WP7 reference metrics (WP1/F1/WP2/WP3/WP4 pinned numbers)"
+echo "[8/12] WP7 reference metrics (WP1/F1/WP2/WP3/WP4 pinned numbers)"
"$BUILD/sim_metrics" generated
-echo "[8/11] WP10c keep-out violation forensics (read-only replay of WP5)"
+echo "[9/12] WP10c keep-out violation forensics (read-only replay of WP5)"
python3 tools/forensics/make_forensics.py
-echo "[9/11] WP7a visualization pack"
+echo "[10/12] WP7a visualization pack"
python3 tools/viz/make_viz.py . generated/viz
-echo "[10/11] WP8 compliance precheck + matrix (not legal advice)"
+echo "[11/12] WP8 compliance precheck + matrix (not legal advice)"
python3 tools/compliance/check_compliance.py
python3 tools/compliance/generate_matrix.py
-echo "[11/11] WP7 evidence pack"
+echo "[12/12] WP7 evidence pack"
python3 tools/evidence/make_evidence.py
echo "regenerate_all: complete"