///|
pub(all) enum CatalogChange {
MetricAdded
MetricRemoved
BaselineGap
CandidateGap
CurrencyChanged
UnitChanged
IdentityChanged
VersionChanged
ModelChanged
PricingChanged
MinimumChanged
ZeroPolicyChanged
} derive(Debug, Eq)
///|
pub extend CatalogChange with Debug::{to_repr}
///|
pub extend CatalogChange with Eq::{equal, not_equal}
///|
/// Identical usage cannot be bound without an explicit mapping. Nonblocking
/// changes still require business review; structural compatibility is not approval.
pub fn CatalogChange::is_blocking(self : CatalogChange) -> Bool {
match self {
MetricAdded
| MetricRemoved
| BaselineGap
| CandidateGap
| CurrencyChanged
| UnitChanged => true
_ => false
}
}
///|
/// Half-open interval, referencing entries in the two trusted catalog snapshots.
pub(all) struct MigrationSpan {
metric : String
starts_on : String
ends_before : String
baseline_entry : Int32?
candidate_entry : Int32?
changes : Array[CatalogChange]
} derive(Debug, Eq)
///|
pub extend MigrationSpan with Debug::{to_repr}
///|
pub extend MigrationSpan with Eq::{equal, not_equal}
///|
/// Unchanged spans are retained to prove full metric and window coverage.
pub(all) struct CatalogMigration {
starts_on : String
ends_before : String
spans : Array[MigrationSpan]
} derive(Debug, Eq)
///|
pub extend CatalogMigration with Debug::{to_repr}
///|
pub extend CatalogMigration with Eq::{equal, not_equal}
///|
fn migration_window(start : String, end : String) -> Unit raise PricingError {
if date_key(end, "migration.ends_before") <=
date_key(start, "migration.starts_on") {
invalid("interval", "migration", "end must be strictly after start")
}
}
///|
fn migration_metrics(before : Catalog, after : Catalog) -> Array[String] {
let metrics = before.metrics.copy()
for m in after.metrics {
if !metrics.contains(m) {
metrics.push(m)
}
}
metrics
}
///|
fn selected_entry(
c : Catalog,
indices : ArrayView[Int32],
day : String,
) -> Int32? {
for i in indices {
let d = c.entries[i]
if d.entry.starts_on <= day &&
(
d.entry.ends_before is None ||
(d.entry.ends_before is Some(end) && day < end)
) {
return Some(i)
}
}
None
}
///|
// Build once per operation. Keep original entry indices and catalog metric order;
// only the hash lookup is shared, not the producer/verifier boundary algorithms.
fn migration_timelines(
c : Catalog,
) -> Map[String, Array[Int32]] raise PricingError {
let timelines : Map[String, Array[Int32]] = Map([])
for i, d in c.entries {
let metric = tariff_info(d.entry.tariff).metric
match timelines.get(metric) {
Some(indices) => indices.push(i)
None => timelines[metric] = [i]
}
}
timelines
}
///|
fn same_tier_prices(a : Plan, b : Plan) -> Bool {
if a.tiers.length() != b.tiers.length() {
return false
}
for i, tier in a.tiers {
if tier.up_to != b.tiers[i].up_to || tier.price != b.tiers[i].price {
return false
}
}
true
}
///|
fn migration_changes(
before : Catalog,
after : Catalog,
metric : String,
bi : Int32?,
ai : Int32?,
) -> Array[CatalogChange] raise PricingError {
let changes : Array[CatalogChange] = []
if !before.metrics.contains(metric) {
changes.push(MetricAdded)
}
if !after.metrics.contains(metric) {
changes.push(MetricRemoved)
}
if before.metrics.contains(metric) && bi is None {
changes.push(BaselineGap)
}
if after.metrics.contains(metric) && ai is None {
changes.push(CandidateGap)
}
if bi is Some(i) && ai is Some(j) {
let bt = before.entries[i].entry.tariff
let at = after.entries[j].entry.tariff
let b = tariff_info(bt)
let a = tariff_info(at)
if b.currency != a.currency {
changes.push(CurrencyChanged)
}
if b.unit != a.unit {
changes.push(UnitChanged)
}
if b.id != a.id {
changes.push(IdentityChanged)
}
if b.version != a.version {
changes.push(VersionChanged)
}
match (bt, at) {
(MeteredTariff(bp, bm, bz), MeteredTariff(ap, am, az)) => {
if bp.mode != ap.mode {
changes.push(ModelChanged)
}
if !same_tier_prices(bp, ap) {
changes.push(PricingChanged)
}
if decimal(bm, "migration.minimum") != decimal(am, "migration.minimum") {
changes.push(MinimumChanged)
}
if bz != az {
changes.push(ZeroPolicyChanged)
}
}
(PackageTariff(bp), PackageTariff(ap)) =>
if bp.size != ap.size || bp.price != ap.price {
changes.push(PricingChanged)
}
_ => changes.push(ModelChanged)
}
}
changes
}
///|
/// Audit every metric throughout the civil-date window. No usage is synthesized
/// and no monetary impact is inferred.
///
/// ```mbt check
/// test {
/// let p = @jieheng.compile_package(
/// id="sms",
/// version="v1",
/// metric="messages",
/// unit="message",
/// currency="USD",
/// package_size="1000",
/// package_price="0.85",
/// )
/// let c = @jieheng.compile_catalog([
/// { starts_on: "2026-10-01", ends_before: None, tariff: PackageTariff(p), },
/// ])
/// let r = @jieheng.audit_catalog_migration(c, c, "2026-10-01", "2026-11-01")
/// inspect(r.spans[0].changes.length(), content="0")
/// }
/// ```
pub fn audit_catalog_migration(
before : Catalog,
after : Catalog,
starts_on : String,
ends_before : String,
) -> CatalogMigration raise PricingError {
migration_window(starts_on, ends_before)
let spans : Array[MigrationSpan] = []
let before_timelines = migration_timelines(before)
let after_timelines = migration_timelines(after)
for metric in migration_metrics(before, after) {
let bi = before_timelines.get(metric).unwrap_or([])
let ai = after_timelines.get(metric).unwrap_or([])
let boundaries = [starts_on, ends_before]
for (c, indices) in [(before, bi), (after, ai)] {
for index in indices {
let d = c.entries[index]
for edge in [Some(d.entry.starts_on), d.entry.ends_before] {
if edge is Some(s) &&
starts_on < s &&
s < ends_before &&
!boundaries.contains(s) {
boundaries.push(s)
}
}
}
}
boundaries.sort()
for i in 0..<(boundaries.length() - 1) {
let start = boundaries[i]
let bi = selected_entry(before, bi[:], start)
let ai = selected_entry(after, ai[:], start)
spans.push({
metric,
starts_on: start,
ends_before: boundaries[i + 1],
baseline_entry: bi,
candidate_entry: ai,
changes: migration_changes(before, after, metric, bi, ai),
})
}
}
{ starts_on, ends_before, spans, }
}
///|
// Independent selection and next-boundary validation, without sorting or rating.
fn checked_migration_boundary(
c : Catalog,
indices : ArrayView[Int32],
start : String,
end : String,
claimed : Int32?,
) -> String raise PricingError {
let mut next = end
let mut actual : Int32? = None
for i in indices {
let d = c.entries[i]
if d.entry.starts_on <= start &&
(
d.entry.ends_before is None ||
(d.entry.ends_before is Some(e) && start < e)
) {
actual = Some(i)
}
for edge in [Some(d.entry.starts_on), d.entry.ends_before] {
if edge is Some(e) && start < e && e < next {
next = e
}
}
}
if claimed != actual {
invalid(
"selection", "migration.spans", "entry index must select the trusted rule covering this metric and date",
)
}
next
}
///|
/// Independently check ordered coverage, maximal intervals and trusted selections.
/// Does not call audit_catalog_migration or its sorted-boundary algorithm.
pub fn verify_catalog_migration(
before : Catalog,
after : Catalog,
starts_on : String,
ends_before : String,
report : CatalogMigration,
) -> Unit raise PricingError {
migration_window(starts_on, ends_before)
if report.starts_on != starts_on || report.ends_before != ends_before {
invalid("identity", "migration", "report must use the trusted date window")
}
if report.spans.length() > 4096 {
invalid("size", "migration.spans", "maximum 4096 spans")
}
let mut row = 0
let before_timelines = migration_timelines(before)
let after_timelines = migration_timelines(after)
for metric in migration_metrics(before, after) {
let bi = before_timelines.get(metric).unwrap_or([])
let ai = after_timelines.get(metric).unwrap_or([])
let mut cursor = starts_on
while cursor < ends_before {
if row >= report.spans.length() {
invalid(
"coverage", "migration.spans", "missing metric or date interval",
)
}
let s = report.spans[row]
if s.metric != metric || s.starts_on != cursor {
invalid(
"coverage",
"migration.spans[\{row}]",
"preserve metric order and contiguous date coverage",
)
}
let bnext = checked_migration_boundary(
before,
bi[:],
cursor,
ends_before,
s.baseline_entry,
)
let anext = checked_migration_boundary(
after,
ai[:],
cursor,
ends_before,
s.candidate_entry,
)
let next = if bnext < anext { bnext } else { anext }
if s.ends_before != next ||
s.changes !=
migration_changes(
before,
after,
metric,
s.baseline_entry,
s.candidate_entry,
) {
invalid(
"changes",
"migration.spans[\{row}]",
"preserve the next trusted boundary and every ordered change",
)
}
cursor = next
row += 1
}
}
if row != report.spans.length() {
invalid("coverage", "migration.spans", "extra or duplicated intervals")
}
}