Files
owniverse/world/owniverse/quantity_math.gd
T

344 lines
15 KiB
GDScript

extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
## Quantity arithmetic without scene tree or global settings.
static var _weight_rules: Dictionary = {}
static var _output_rules: Dictionary = {}
## Parsed rules are shared read-only; the string itself is the cache key.
static func rule_weights(text: String) -> Dictionary:
if _weight_rules.has(text): return _weight_rules[text]
var result: Dictionary = {}
for term in text.split("^", false):
var pair: PackedStringArray = term.split(":")
result[pair[0]] = E.of(pair[1])
if _weight_rules.size() >= 128: _weight_rules.clear()
result.make_read_only()
_weight_rules[text] = result
return result
static func output_terms(text: String) -> Array:
if _output_rules.has(text): return _output_rules[text]
var result: Array = []
for term in text.split("&"):
var pair: PackedStringArray = term.strip_edges().split(":")
if pair[0].strip_edges().is_empty(): continue
result.append({"id": pair[0].strip_edges(), "amount": E.of(pair[1]) if E.eq(pair.size(), 2) else 1})
if _output_rules.size() >= 128: _output_rules.clear()
result.make_read_only()
_output_rules[text] = result
return result
static func accumulate_whole(target: Dictionary, field: String, key: String, value: Variant) -> Variant:
if (not target.has(field)):
target[field] = {}
var expected: Variant = E.add(E.maximum(0, value), target[field].get(key, 0))
var whole: Variant = E.floor_value(expected)
target[field][key] = E.maximum(0, E.subtract(expected, whole))
return whole
static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary:
var result: Dictionary = {}
var total: Variant = 0
for id in requests:
result[id] = E.floor_value(E.maximum(0, requests[id]))
total = E.add(total, result[id])
return _allocate_whole_with_total(requests, limit, result, total)
## Whole stocks apportioned using float shares. Both the total and every stock
## ceiling are enforced exactly after quota rounding; no quantity becomes float.
static func allocate_profiled(stocks: Dictionary, limit: Variant) -> Dictionary:
var total: Variant = sum(stocks)
var available: Variant = E.floor_value(E.minimum(E.maximum(0, limit), total))
if E.eq(available, total): return stocks.duplicate()
var ids: Array = stocks.keys()
var shares: Array = []
var result: Dictionary = {}
var only: Variant = null
var positives: int = 0
for id in ids:
result[id] = 0
if E.gt(stocks[id], 0):
only = id
positives += 1
if E.le(available, 0): return result
if positives == 1:
result[only] = available
return result
for id in ids: shares.append(E.ratio_approx(stocks[id], total))
result = split_profile({}, "stock", available, ids, shares)
var assigned: Variant = 0
for id in ids:
result[id] = E.minimum(stocks[id], result[id])
assigned = E.add(assigned, result[id])
var missing: Variant = E.subtract(available, assigned)
for id in ids:
if E.le(missing, 0): break
var extra: Variant = E.minimum(missing, E.subtract(stocks[id], result[id]))
result[id] = E.add(result[id], extra)
missing = E.subtract(missing, extra)
assert(E.eq(missing, 0), "Whole stock allocation must exhaust the available quota")
return result
## Variant for callers that already maintain the sum of floored request values.
## The supplied total must equal sum(floor(max(0, requests[id]))).
static func allocate_whole_with_total(requests: Dictionary, limit: Variant, request_total: Variant) -> Dictionary:
var result: Dictionary = {}
for id in requests: result[id] = E.floor_value(E.maximum(0, requests[id]))
return _allocate_whole_with_total(requests, limit, result, request_total)
static func _allocate_whole_with_total(requests: Dictionary, limit: Variant, result: Dictionary, total: Variant) -> Dictionary:
var available: Variant = E.floor_value(E.minimum(E.maximum(0, limit), total))
# No apportionment or rounding is necessary when every request fits.
if E.eq(available, total): return result
if E.le(available, 0):
for id in result: result[id] = 0
return result
var only: Variant = null
var positive_count: int = 0
for id in result:
if E.gt(result[id], 0):
only = id
positive_count += 1
if positive_count == 1:
result[only] = available
return result
var ids: Array = requests.keys()
var rests: Array = []
var order: Array = []
var assigned: Variant = 0
for i in ids.size():
var expected: Variant = E.mul_div(result[ids[i]], available, total)
result[ids[i]] = E.floor_value(expected)
assigned = E.add(assigned, result[ids[i]])
rests.append(E.subtract(expected, result[ids[i]]))
order.append(i)
order.sort_custom(func(a: int, b: int) -> bool: return E.gt(rests[a], rests[b]) if E.ne(rests[a], rests[b]) else str(ids[a]) < str(ids[b]))
for i in order:
if E.ge(assigned, available): break
if E.lt(result[ids[i]], E.floor_value(requests[ids[i]])):
result[ids[i]] = E.add(result[ids[i]], 1)
assigned = E.add(assigned, 1)
return result
static func production_step(costs: Array):
var step = 1
for cost in costs:
if E.le(cost, 0): continue
var terms: Array = E.parts(cost)
var denominator = E.make(E.Big.divmod(terms[1], E.Big.gcd(terms[0], terms[1]))[0])
step = E.multiply(E.divide(step, whole_gcd(step, denominator)), denominator)
return step
static func whole_gcd(a: Variant, b: Variant) -> Variant:
while E.gt(b, 0):
var remainder: Variant = E.remainder(a, b)
a = b
b = remainder
return a
static func normalize_distribution(values: Dictionary) -> Dictionary:
var normalized_distribution: Dictionary = {}
var distribution_total: Variant = 0
for id in values:
if E.gt(values[id], 0):
distribution_total = E.add(distribution_total, values[id])
normalized_distribution[id] = values[id]
for id in normalized_distribution:
normalized_distribution[id] = E.divide(normalized_distribution[id], distribution_total)
return normalized_distribution
static func sum_array(values: Array) -> Variant:
var result: Variant = 0
for value in values: result = E.add(result, value)
return result
static func sum(pop: Dictionary) -> Variant:
var result: Variant = 0
for n in pop.values(): result = E.add(result, n)
return result
static var _split_keys: Dictionary = {}
## Split a whole population using normalized float shares. Quantizing the six
## shares to 15 decimal places matches their stored float precision, while the
## integer denominator avoids a second expensive exact normalization pass.
static func split_profile(remainders: Dictionary, key: String, count: Variant, ids: Array, shares: Array) -> Dictionary:
var result: Dictionary = {}
if E.le(count, 0): return result
assert(E.eq(count, E.floor_value(count)) and ids.size() == shares.size() and not ids.is_empty())
const SCALE: int = 1000000000000000
var weights: Array = []
var assigned_weight: int = 0
for i in ids.size() - 1:
var weight: int = clampi(roundi(clampf(float(shares[i]), 0.0, 1.0) * SCALE), 0, SCALE)
weights.append(weight)
assigned_weight += weight
var last_weight: int = SCALE - assigned_weight
if last_weight < 0:
var excess: int = -last_weight
for i in range(weights.size() - 1, -1, -1):
var reduction: int = mini(excess, weights[i])
weights[i] -= reduction
excess -= reduction
if excess == 0: break
last_weight = 0
weights.append(last_weight)
var cached: Dictionary = _split_keys.get(key, {})
if cached.is_empty() or cached.ids != ids:
var keys: Array = []
for id in ids: keys.append(key + ":" + str(id))
if _split_keys.size() >= 512: _split_keys.clear()
cached = {"ids": ids.duplicate(), "keys": keys}
_split_keys[key] = cached
var quotas: Array = E.whole_quotas(count, weights, SCALE)
var rests: Array = []
var order: Array = []
var total: Variant = 0
for i in ids.size():
var fraction: float = quotas[i][1] + E.approx(remainders.get(cached.keys[i], 0))
var adjustment: int = floori(fraction)
result[ids[i]] = E.maximum(0, E.add(quotas[i][0], adjustment))
total = E.add(total, result[ids[i]])
rests.append(fraction - adjustment if E.ge(quotas[i][0], -adjustment) else fraction)
order.append(i)
order.sort_custom(func(a: int, b: int) -> bool: return rests[a] > rests[b] if rests[a] != rests[b] else str(ids[a]) < str(ids[b]))
if E.lt(total, count):
for i in order:
if E.ge(total, count): break
result[ids[i]] = E.add(result[ids[i]], 1)
rests[i] -= 1.0
total = E.add(total, 1)
elif E.gt(total, count):
order.reverse()
for i in order:
if E.le(total, count): break
if E.le(result[ids[i]], 0): continue
result[ids[i]] = E.subtract(result[ids[i]], 1)
rests[i] += 1.0
total = E.subtract(total, 1)
for i in ids.size():
remainders[cached.keys[i]] = clampf(rests[i], -1.0, 1.0)
return result
static func split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
if E.le(count, 0): return {}
return split_arrays(remainders, key, count, weights.keys(), weights.values())
static func split_arrays(remainders: Dictionary, key: String, count: Variant, ids: Array, weights: Array, population_factor: int = 0, skip_conserved_identity: bool = false) -> Dictionary:
var result: Dictionary = {}
if E.le(count, 0): return result
var sum_weights: Variant = 0
var positive_index: int = -1
var positive_count: int = 0
for i in weights.size():
sum_weights = E.add(sum_weights, weights[i])
if E.gt(weights[i], 0):
positive_index = i
positive_count += 1
if E.le(sum_weights, 0): return result
var cached: Dictionary = _split_keys.get(key, {})
if cached.is_empty() or cached.ids != ids:
var keys: Array = []
for id in ids: keys.append(key + ":" + str(id))
if _split_keys.size() >= 512: _split_keys.clear()
cached = {"ids": ids.duplicate(), "keys": keys}
_split_keys[key] = cached
var remainder_keys: Array = cached.keys
# A single branch is exact only when no saved rounding error redistributes it.
var single: bool = positive_count == 1 and E.eq(count, E.floor_value(count))
if single:
for i in ids.size():
if E.lt(weights[i], 0) or E.ne(remainders.get(remainder_keys[i], 0), 0):
single = false
break
if single:
for i in ids.size():
result[ids[i]] = count if i == positive_index else 0
remainders[remainder_keys[i]] = 0
return result
var expected: Array = []
if population_factor > 0 and E.eq(sum_weights, E.multiply(count, population_factor)):
# Ordinary drift: count cancels exactly from count*weight/(factor*count).
expected = E.population_quotas(count, weights, population_factor)
else:
expected = E.mul_div_many(count, weights, sum_weights)
var positives: Array = []
var positive_sum: Variant = 0
var whole_quotas: bool = true
for i in ids.size():
expected[i] = E.add(expected[i], remainders.get(remainder_keys[i], 0))
var positive: Variant = E.maximum(0, expected[i])
whole_quotas = whole_quotas and E.eq(positive, E.floor_value(positive))
positives.append(positive)
positive_sum = E.add(positive_sum, positive)
# Fractional identity ratios still round-trip through parts/make in the
# original algorithm. Retain them to preserve ties and saved errors exactly.
var normalized: Array = positives
if E.gt(positive_sum, 0) and (E.ne(positive_sum, count) or (not whole_quotas and not (skip_conserved_identity and E.eq(positive_sum, count)))):
normalized = E.mul_div_many(count, positives, positive_sum)
var order: Array = []
var rests: Array = []
var assigned: Variant = 0
for i in ids.size():
result[ids[i]] = E.floor_value(normalized[i])
assigned = E.add(assigned, result[ids[i]])
rests.append(E.subtract(normalized[i], result[ids[i]]))
order.append(i)
order.sort_custom(func(a: int, b: int) -> bool: return E.gt(rests[a], rests[b]) if E.ne(rests[a], rests[b]) else str(ids[a]) < str(ids[b]))
for i in order:
if E.ge(assigned, count): break
result[ids[i]] = E.add(result[ids[i]], 1)
assigned = E.add(assigned, 1)
for i in ids.size():
remainders[remainder_keys[i]] = E.clamp_value(E.subtract(expected[i], result[ids[i]]), E.neg(1), 1)
return result
static func accumulate_rate(remainders: Dictionary, key: String, count: Variant, probability: Variant) -> Variant:
# Reward rates are floats while populations are exact whole values. Keep
# the product as quotient plus fractional carry instead of constructing an
# intermediate Exact Value and flooring it again.
if probability is float and is_finite(probability) and probability >= 0.0 and (count is int or (count is E.Value and count.fraction == 0.0)) and E.ge(count, 0):
var terms: Array = E.parts(probability)
if terms[2] > 0:
var population: Array = E._blocks(count)[0]
var whole_product: Array = E.Big.multiply(population, terms[0])
var qr: Array = E.Big.divmod(whole_product, terms[1])
var fractional: float = E._fraction_ratio(qr[1], terms[1])
var carry: float = E.approx(remainders.get(key, 0))
if carry >= 0.0 and carry < 1.0:
if qr[1] == [0]:
remainders[key] = carry
return E._compose(qr[0])
fractional += carry
if fractional > 0.5 and absf(fractional - roundf(fractional)) <= 1e-12:
fractional = roundf(fractional)
var extra: int = floori(fractional)
remainders[key] = fractional - extra
return E.add(E._compose(qr[0]), extra)
var product: Variant = E.multiply(count, E.maximum(0, probability))
var carry: float = E.approx(remainders.get(key, 0))
if carry >= 0.0 and carry < 1.0 and E.ge(product, 0):
if product is int:
remainders[key] = carry
return product
if product is E.Value:
var fraction: float = product.fraction + carry
if product.fraction > 0.0 and carry > 0.0 and fraction > 0.5 and absf(fraction - roundf(fraction)) <= 1e-12:
fraction = roundf(fraction)
var extra: int = floori(fraction)
remainders[key] = fraction - extra
return E.add(E.floor_value(product), extra)
var expected: Variant = E.add(product, remainders.get(key, 0))
var actual: Variant = E.floor_value(expected)
remainders[key] = E.maximum(0, E.subtract(expected, actual))
return actual