Aetheria/tests/mutation_tests_stats_power_nominal_request.py
Meta Vens 8bbf280756 Resolve request/brownout conflict: evaluate power requests nominally
A power request may not depend on power supply (Cut 6/F6), and continuous
consumers brown out through a power term on their own performance stats
(Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the
same stat, making six shipped catalog records illegal and leaving six
BrownoutTests cases skipped.

Resolved per the operator's ruling: a power request is evaluated
nominally -- what the item wants at full supply, not what it is currently
managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's
own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability
stay live, since they are not the term a request would be circular
through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor
implementation now reads a StatValidation.PowerRequestFields stat through
it, so the same stat can carry a brownout curve and be a request field
without the request depending on its own answer.

StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the
rule, forbidding the term outright) is deleted -- nominal evaluation
removes the circularity at the root instead. Only the dynamic
modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still
refuses anything, since EvaluateNominalPower forwards ScaleModifier/
ConstantModifier unchanged (real, not nominal).

Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new
regression tests (a direct EvaluateNominalPower probe, and a two-tick
Radiator demand check) backed by a new mutation harness. Updates the four
PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded
static refusal to instead assert acceptance. GameData/Aetheria.cc is
unchanged and still validates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:58:57 +02:00

161 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""Mutation tests for docs/stats-and-power-cut.md's nominal-request ruling (operator ruling 2026-09-19).
Two landed rulings collided: a stat deciding a power request may not depend on power supply (Cut 6, widened by
F6 to name every stat a PowerRequest actually reads), and continuous consumers brown out through a power term on
their performance stats (Cut 7). For Radiator and AetherDrive those are the same stat (RadiatorData.PumpedHeat,
AetherDriveData.Torque), which made six shipped catalog records illegal and left six BrownoutTests.cs cases
skipped.
The resolution: a power request is evaluated NOMINALLY -- what the item wants at full supply, not what it is
currently managing. EquippedItem.EvaluateNominalPower (Entity.cs) is the read every PowerRequest/RefreshReserve/
RefreshInputCapacitor implementation now uses for a StatValidation.PowerRequestFields stat; it pins that stat's
own PowerSupplyFactor to 1 regardless of what Terms it declares, removing the circularity at the root instead of
forbidding the field (StatValidation.ValidateNoPowerSupplyOnRequest, the static half of Cut 6/F6's rule, is
deleted -- see mutation_tests_stats_power_cut6.py's own updated header). This script pins that EvaluateNominalPower
actually forces the pin (rather than silently falling back to the real, curved value) and that Radiator.PowerRequest
actually calls it instead of the ordinary Evaluate. AetherDrive.PowerRequest's own Torque read has no equivalent
black-box mutation here -- its torqueRatio term self-cancels Torque's own magnitude outside the rpm cap, so
reverting that one call site produces byte-identical demand in every fixture tried (see the comment in
BrownoutTests.cs, right after AetherDriveAtZeroGrantProducesNothing, for the empirical finding); its correctness
rests on the identical pattern to Radiator's own call site instead.
Usage:
python tests/mutation_tests_stats_power_nominal_request.py --cultlib-root <path-to-CultLib-45c2f40-worktree>
Only touches files under this repository; every mutation is reversed before the script exits, including on
failure.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests"
@dataclass
class Mutation:
rule: str
file: str
anchor: str
mutated: str
test: str
expect: str # "red" (mutant must be killed) or "green" (the no-op control)
MUTATIONS: list[Mutation] = [
# --- No-op control: proves the anchor/replace/restore mechanism is transparent ---
Mutation(
rule="control (no-op round trip)",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" public PowerRequestContext(EquippedItem item) => _item = item;\n public Lot Lot => _item.Lot;\n public float HeatFactor(float exponent) => _item.HeatFactor(exponent);\n public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);\n public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;",
mutated=" public PowerRequestContext(EquippedItem item) => _item = item;\n public Lot Lot => _item.Lot;\n public float HeatFactor(float exponent) => _item.HeatFactor(exponent);\n public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);\n public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;",
test="BrownoutTests.EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant",
expect="green",
),
# --- The pin itself: EvaluateNominalPower's own context must actually return the identity for PowerSupply,
# --- not forward it to the real item -- otherwise EvaluateNominalPower is just Evaluate under another name,
# --- and every PowerRequest/RefreshReserve/RefreshInputCapacitor call site built on it stays exactly as
# --- circular as before this ruling (the oscillation Soul originally measured against the shipped catalog).
Mutation(
rule="PowerRequestContext.PowerSupplyFactor must return the identity (1), not forward to the real item",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=" public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;\n public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);\n public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);\n }\n\n public float HeatFactor(float exponent)",
mutated=" public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => _item.PowerSupplyFactor(exponent);\n public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);\n public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);\n }\n\n public float HeatFactor(float exponent)",
test="BrownoutTests.EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant",
expect="red",
),
# --- Radiator.PowerRequest must actually call the nominal read for PumpedHeat/WasteHeat/EnergyUsage, not the
# --- ordinary curved Evaluate. A single-tick fixture cannot distinguish the two (Item.PowerSupply defaults to
# --- 1 before the bus has ever run, so tick 1 reads the same value either way) -- this fixture runs two
# --- ticks, so tick 2 sees whatever tick 1 actually granted. Tuned so nominal PumpedHeat/WasteHeat (10/5 = 2)
# --- stays comfortably above tempRatio (pinned near 1) while the curved value at tick 1's partial grant
# --- (pow(.4, 1)*10/5 = .8) falls below it -- reverting to Evaluate collapses tick 2's demand through
# --- PowerRequest's own early-out gate instead of leaving it open. AetherDrive.PowerRequest's own Torque read
# --- has no equivalent black-box mutation: see the comment in BrownoutTests.cs above
# --- AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp for why (torqueRatio self-cancels Torque's own
# --- magnitude outside the rpm cap, verified empirically while building this script).
Mutation(
rule="Radiator.PowerRequest must read PumpedHeat/WasteHeat/EnergyUsage nominally, not via the curved Evaluate",
file="Assets/Scripts/ServerShared/Behaviors/Radiator.cs",
anchor=" var pumpedHeat = EvaluateNominalPower(_data.PumpedHeat);\n var wasteHeat = EvaluateNominalPower(_data.WasteHeat);\n var tempRatio = max(RadiatorTemperature / Temperature, 1);\n if (tempRatio > pumpedHeat / wasteHeat) return 0f;\n return EvaluateNominalPower(_data.EnergyUsage) * tempRatio * dt;",
mutated=" var pumpedHeat = Evaluate(_data.PumpedHeat);\n var wasteHeat = Evaluate(_data.WasteHeat);\n var tempRatio = max(RadiatorTemperature / Temperature, 1);\n if (tempRatio > pumpedHeat / wasteHeat) return 0f;\n return Evaluate(_data.EnergyUsage) * tempRatio * dt;",
test="BrownoutTests.RadiatorSecondTickDemandIsUnaffectedByTheFirstTicksPartialGrant",
expect="red",
),
]
def replace_unique(path: Path, anchor: str, mutated: str) -> None:
original = path.read_text(encoding="utf-8")
count = original.count(anchor)
if count != 1:
raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}")
path.write_text(original.replace(anchor, mutated), encoding="utf-8")
def run_test(cultlib_root: str, test: str) -> tuple[int, str]:
proc = subprocess.run(
["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}",
"--filter", f"FullyQualifiedName~{test}"],
capture_output=True, text=True, timeout=300,
)
return proc.returncode, proc.stdout + proc.stderr
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cultlib-root", required=True)
args = parser.parse_args()
failures: list[str] = []
originals: dict[str, str] = {}
for m in MUTATIONS:
if m.file not in originals:
originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8")
def restore_all() -> None:
for file, text in originals.items():
(REPO_ROOT / file).write_text(text, encoding="utf-8")
try:
for m in MUTATIONS:
print(f"\n=== {m.rule} (expect {m.expect}) ===")
path = REPO_ROOT / m.file
replace_unique(path, m.anchor, m.mutated)
code, output = run_test(args.cultlib_root, m.test)
passed = "Passed!" in output and code == 0
if m.expect == "green":
if not passed:
failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}")
else:
print(f"OK: {m.test} passed under a no-op mutation, as expected")
else:
if passed:
failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed")
else:
print(f"OK: {m.test} went red under the mutation, as expected")
# Restore this file immediately so the next mutation starts from a clean tree.
path.write_text(originals[m.file], encoding="utf-8")
finally:
restore_all()
if failures:
print("\n=== FAILURES ===")
for f in failures:
print(f"- {f}")
return 1
print("\nAll mutations behaved as declared.")
return 0
if __name__ == "__main__":
sys.exit(main())