sots-re/tools/max_income_predict.py
alex 3c48d3a39e lane N: the population -> base-output term, read and live-verified
findings/subsystems/output-term.md is the whole reading: the call chain with
real function boundaries, the formula, the closed list of nine values the data
files supply (down from an unbounded fear), the advance prediction with its
falsification table, and the live result.

Corrects strategic-turn-internals.md 3.3 in place. That block had the SHAPE
wrong, not just the detail: a system's output is a sum of three terms, and the
function it named as the population base-output term is the over-harvest
resource demand.

Live on VM140, two builds, two species, both hooks in compare mode:
GroupOutput 13,105 calls / 0 divergences, ComputeTotalOutput 11,252 / 1 (one
ulp), 0 undeclared writes in 24,357 guarded calls. Thirteen distinct system
states, and every unexercised branch is listed rather than counted as covered.

tools/max_income_predict.py is the other half: it computes lane Y's
bankruptcy-limit oracle from colony state and reports 6 of 25 player-records
matching exactly, with the misses all AI-owned and the single-system ones short
by exactly the 1.1 difficulty income multiplier.
2026-09-08 12:11:52 -04:00

345 lines
12 KiB
Python

#!/usr/bin/env python3
"""Predict each player's maxIncome from a save and check it against the bankruptcy oracle.
`tools/max_income_oracle.py` (lane Y) inverts the stored `BnkEl` to recover
maxIncome = sum over owned systems of max(ComputeMaxIncome(s), 0)
which is the per-system money output that blocks `ComputeBudget` and four other phases.
This script goes the other way: it computes the same number from the colony state and
compares, so the formula is falsified per player-record rather than per lane.
The chain it implements (lane N, findings/subsystems/output-term.md, live-verified on VM140):
total = ComputeTotalOutput(SRoh = 0) # max mods -> trade rate 1, rest 0
= ( overHarvestDemand x speciesResourceOutput
+ (TRes + resAvail) x stripMineFraction x 0.9
+ populationOutput )
x OutMod x sysOutMod x setupOutMod x RebOutMod x ScOutMod
trade = roundHalfEven(total)
money = ftol( TradePointsToMoney(trade) )
Population output per head is `typeOutputMod x 1.8 / 500000`; population INCOME per head is
`typeIncomeModifier / 14000`, with no 1.8 -- two different laws off the same table.
Everything the executable carries is hard-coded here as such. The two per-species fields the
data files supply (SpeciesDef +0x4c base resource demand, +0x50 resource output factor) are
options, defaulting to the values measured live on VM140 for the species this corpus contains.
"""
import argparse
import json
import math
import os
import struct
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "verify", "save-reader"))
import save_reader as sr # noqa: E402
# ---- constants the executable carries itself ------------------------------------------------
OUT_FACTOR = struct.unpack("<d", bytes.fromhex("ccccccccccccfc3f"))[0] # 1.8, as the image holds it
OUT_DIVISOR = 500000.0
INCOME_DIVISOR = 14000.0
RES_FACTOR = struct.unpack("<d", bytes.fromhex("cdccccccccccec3f"))[0] # 0.9
POPTYPE_OUT = {0: 1.0, 1: struct.unpack("<f", struct.pack("<f", 0.33))[0]}
POPTYPE_INC = {0: 1.0, 1: struct.unpack("<f", struct.pack("<f", 0.33))[0]}
# ---- values the data files supply, measured live on VM140 (2026-09-08) ----------------------
SPECIES_BASE_DEMAND = 0 # SpeciesDef +0x4c
SPECIES_RES_OUTPUT = 10.0 # SpeciesDef +0x50
def f32(v):
return struct.unpack("<f", struct.pack("<f", v))[0]
def ftol(v):
return math.trunc(v)
def round_half_even(v):
# `fistp`/`fild` with the x87 in round-to-nearest: ties go to the even neighbour.
f = math.floor(v)
d = v - f
if d > 0.5:
return f + 1.0
if d < 0.5:
return float(f)
return float(f if f % 2 == 0 else f + 1)
def clamp01(v):
return 0.0 if v < 0 else (1.0 if v > 1 else v)
def signed_cbrt(x):
return math.pow(x, 1.0 / 3.0) if x >= 0 else -math.pow(-x, 1.0 / 3.0)
def strip_mine_fraction(pop, infra, ibon):
fi = f32(ibon + infra)
r = clamp01(signed_cbrt(pop / 100.0) * 0.01)
if 0.0001 + r >= 1.0:
r = fi
return f32(r if r < fi else fi)
def over_harvest_demand(rate, avail, pop, base_demand):
b = 0.0
if rate > 0.0:
v = rate * float(avail) * clamp01(float(pop) * 1e-05)
b = v if v > 1.0 else 1.0
t = float(base_demand) + b
lo = t if t > 0.0 else 0.0
return float(avail) if float(avail) < lo else lo
def group_output(group, count, morale, stations, tuning, owned, independent):
if not count > 0:
return 0.0
q = float(count) / OUT_DIVISOR
sf = 1.0
if owned and group == 0:
b = tuning["STATION_BONUS_IMPERIAL_OUTPUT"]
sf = 1.0 + stations * (b if b > 0 else 0.0)
mo = 1.0
if group == 1 and owned and not independent:
mo = morale_output_mod(morale, tuning)
v = POPTYPE_OUT[group] * (sf * OUT_FACTOR) * mo * q
return v if v > 0 else 0.0
def morale_output_mod(m, tuning):
if m == 0:
return 1.0
if m >= tuning["MORALE_INCREASE_OUTPUT"]:
x = tuning["MORALE_INCREASE_OUTPUT_MOD"]
return x if x > 0 else 1.0
if m <= tuning["MORALE_DECREASE_OUTPUT"]:
x = tuning["MORALE_DECREASE_OUTPUT_MOD"]
return x if x > 0 else 1.0
return 1.0
# The tuning values this corpus runs with, read out of the live process on VM140.
LIVE_TUNING = {
"STATION_BONUS_IMPERIAL_OUTPUT": f32(0.1),
"MORALE_INCREASE_OUTPUT": 85,
"MORALE_INCREASE_OUTPUT_MOD": f32(1.5),
"MORALE_DECREASE_OUTPUT": 20,
"MORALE_DECREASE_OUTPUT_MOD": f32(0.5),
"SLAVES_OUTPUT_MOD": f32(3.0),
}
# ---- save reading ---------------------------------------------------------------------------
def kids(n):
return n.children or []
def walk(node, path=""):
yield path, node
for c in kids(node):
yield from walk(c, path + "/" + (c.name or "?"))
def field(node, name, default=None):
for c in kids(node):
if c.name == name:
return c.value
return default
def sub(node, name):
for c in kids(node):
if c.name == name:
return c
return None
def population(node, name, group, species):
p = sub(node, name)
if p is None:
return 0
total = 0
for g in kids(p):
d = {c.name: c.value for c in kids(g)}
if d.get("PopT") == group and d.get("PopS") == species:
total += d.get("PopC") or 0
return total
def morale_table(node):
"""`cm` on the wire is a count then (species, value) pairs."""
cm = sub(node, "cm")
out = {}
if cm is None:
return out
pending = None
for c in kids(cm):
if c.name == "msp":
pending = c.value
elif c.name == "mv" and pending is not None:
out[pending] = c.value
pending = None
return out
def read_state(path):
r = sr.read_save(path)
systems, players = [], []
for _, n in walk(r.tree):
names = {c.name for c in kids(n)}
if {"Pop", "Rts", "Infra", "pbon"} <= names:
systems.append(n)
elif {"OutMod", "IncMod", "ScOutMod", "BnkEl", "PlyrIdx"} <= names:
players.append(n)
return systems, players
def system_species(node, owner_species):
"""The species the system's population is credited to.
`indi` is written unconditionally and reads back as a zero-filled record on an ordinary
colony; the gate is the separate `hindi` bool. Reading `indsp` without checking `hindi`
silently credits every colony to species 0 -- which agrees with the oracle on a HUMAN
empire and disagrees on every other, the exact failure mode rule 8 warns about.
"""
if field(node, "hindi"):
indi = sub(node, "indi")
if indi is not None:
s = field(indi, "indsp")
if s is not None:
return s
return owner_species
def is_independent(node):
return bool(field(node, "hindi"))
def predict(save, base_demand, res_output, verbose=False):
"""Return {BnkEl: (predicted maxIncome, per-system detail)}.
The join key is `BnkEl` rather than any id: a system stores its owner as a HANDLE id
(`PID`), a player stores an ordinal (`PlyrIdx`), and the two are different numbering
schemes. The oracle inverts the same `BnkEl` the player node carries, so keying on it
needs no id mapping at all and cannot silently pair the wrong two records.
"""
systems, players = read_state(save)
# A player's handle id is not a named field, but every system names its owner's, so the
# set of distinct non-zero `PID` values is the set of owning players -- in the same order
# the player records appear. Pair them by position among the players that own anything.
owner_handles = []
for s in systems:
h = field(s, "PID")
if h and h not in owner_handles:
owner_handles.append(h)
owner_handles.sort()
owners = [p for p in players if (field(p, "NumOwn") or 0) > 0]
handle_of = {}
if len(owners) == len(owner_handles):
for p, h in zip(owners, owner_handles):
handle_of[id(p)] = h
out = {}
for p in players:
h = handle_of.get(id(p))
total = 0
detail = []
if h is not None:
for s in systems:
if field(s, "PID") != h:
continue
m = system_income(s, p, base_demand, res_output)
detail.append((field(s, "Idx"), field(s, "Name"), m))
total += max(m, 0)
out[field(p, "BnkEl")] = (total, detail)
if verbose and detail:
print(" player handle %s (%d system(s))" % (h, len(detail)))
for sid, nm, m in detail:
print(" sys %-4s %-16s money=%d" % (sid, nm, m))
return out
def system_income(s, p, base_demand, res_output):
rts = sub(s, "Rts")
owner_species = field(p, "Species")
sp = system_species(s, owner_species)
strip = bool(field(p, "AMine"))
res = field(s, "Res") or 0
avail = res + ((field(s, "MRes") or 0) + (field(s, "ARes2") or 0) if strip else 0)
pop = (field(s, "Pop") or 0) + (field(s, "pbon") or 0)
civ = population(s, "Pop2", 1, sp) + population(s, "pbon2", 1, sp)
morale = morale_table(s).get(sp, 0)
independent = is_independent(s)
harvest = over_harvest_demand(0.0, avail, pop, base_demand) * f32(res_output)
resource = (float((field(s, "TRes") or 0) + avail)
* float(strip_mine_fraction(pop, field(s, "Infra") or 0.0, field(s, "ibon") or 0.0))
* RES_FACTOR)
imperial = group_output(0, pop, 0, 0, LIVE_TUNING, True, independent)
civilian = group_output(1, civ, morale, 0, LIVE_TUNING, True, independent)
base = (civilian + (imperial + 0.0)) + (harvest + resource)
if field(s, "rbfl"):
return 0
total = base
total *= f32(field(p, "OutMod") or 1.0)
total *= f32(field(s, "OutMod") or 1.0)
total *= f32(field(p, "RebOutMod") or 1.0)
total *= f32(field(p, "ScOutMod") or 1.0)
trade = round_half_even(total)
t = (trade - math.fmod(trade, 5.0)) * 5.0
t += ftol(POPTYPE_INC[0] * (float(pop) / INCOME_DIVISOR))
t += ftol(POPTYPE_INC[1] * (float(civ) / INCOME_DIVISOR))
t *= f32(field(p, "IncMod") or 1.0)
return ftol(t)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("saves", nargs="*")
ap.add_argument("--oracle", help="JSON from max_income_oracle.py --json")
ap.add_argument("--base-demand", type=int, default=SPECIES_BASE_DEMAND)
ap.add_argument("--res-output", type=float, default=SPECIES_RES_OUTPUT)
ap.add_argument("-v", "--verbose", action="store_true")
a = ap.parse_args()
oracle = {}
if a.oracle:
with open(a.oracle) as fh:
raw = json.load(fh)
for row in (raw if isinstance(raw, list) else raw.get("records", [])):
oracle[(os.path.basename(row.get("save", "")), row.get("BnkEl"))] = row.get("maxIncome")
hits = misses = unknown = 0
for save in a.saves:
name = os.path.basename(save)
print("==", name)
for bnkel, (total, detail) in sorted(predict(save, a.base_demand, a.res_output,
a.verbose).items()):
if not detail:
continue
want = oracle.get((name, bnkel))
if want is None:
unknown += 1
print(" BnkEl=%-12s predicted=%-12d (no oracle record)" % (bnkel, total))
elif want == total:
hits += 1
print(" BnkEl=%-12s predicted=%-12d MATCH" % (bnkel, total))
else:
misses += 1
print(" BnkEl=%-12s predicted=%-12d oracle=%-12d delta=%+d (%.4f x)"
% (bnkel, total, want, total - want,
(total / want) if want else float("nan")))
print("\n%d match, %d differ, %d with no oracle record" % (hits, misses, unknown))
return 1 if misses else 0
if __name__ == "__main__":
raise SystemExit(main())