Cut 0: pin the collapsed evaluation path's agreements and disagreements

Three new LoadoutTests, each a check the cut map requires (docs/stats-and-
power-cut.md Cut 0):

- UnequippedAgreesWithEquippedAtFullHealthAndOptimalTemperature: the
  agreement the map names as the cut's own test. Real catalog curves rarely
  sample to exactly 1.0 at their reported OptimalTemperature (a probe found
  12/30 heat-bearing stats disagreeing there), so this pins the design
  contract with a controlled flat-topped fixture rather than live data --
  heat curve semantics are Cut 1's R-heat ruling, not this cut's.
- UnequippedIgnoresHeatRegardlessOfTemperature and
  ConsumableEvaluateSubstitutesProgressForHeatUnconditionally: the two named
  disagreements the cut preserves rather than resolves.

tests/mutation_tests_stats_power_cut0.py mutates each context's identity
factor (the constant a context returns instead of a term it structurally
cannot have) and asserts the right test goes red; a no-op control proves the
harness itself is transparent. All four cases pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-18 16:19:33 +02:00
commit 67217ed97c
2 changed files with 286 additions and 0 deletions

View file

@ -597,6 +597,141 @@ public sealed class LoadoutTests : IDisposable
Assert.NotEqual(lowEquipped.ThermalExponent, highEquipped.ThermalExponent);
}
// Cut 0 (docs/stats-and-power-cut.md): the collapsed evaluation path (PerformanceStat.Evaluate(IStatContext))
// gives the unequipped and the equipped-at-full-health-and-optimal-temperature cases the same number, for a
// design whose heat curve genuinely reaches full performance at its plateau. Before the cut this equality was
// not even expressible: the two paths were separate hand-written formulas (ItemManager.Evaluate and
// EquippedItem.Evaluate) with no shared entry point to assert against. It is expressible now because both
// resolve through the one function via an IStatContext, which is the structural point of the cut.
//
// Real catalog curves rarely land on exactly 1.0 at their reported OptimalTemperature (64-sample search over
// an authored Bezier) -- a probe against GameData/Aetheria.cc found 12 of 30 heat-bearing stats disagreeing
// between the unequipped and full-health/optimal-temperature equipped reads, by up to ~100 units on a
// Min/Max=0/1e6 stat. That is real, current, and *not* fixed here: heat curve semantics are Cut 1's R-heat
// ruling, not Cut 0's. This test pins the design contract with a controlled fixture (a flat-topped curve
// whose plateau samples to exactly 1.0) rather than live data, so it is not a claim that every real item
// agrees today.
[Fact]
public void UnequippedAgreesWithEquippedAtFullHealthAndOptimalTemperature()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
var hullData = cache.GetByName<HullData>("Skiff");
var flatCurve = new BezierCurve
{
Keys = new[]
{
new float4(0f, 0f, 0f, 0f),
new float4(.4f, 1f, 0f, 0f),
new float4(.6f, 1f, 0f, 0f),
new float4(1f, 0f, 0f, 0f),
}
};
var thermalGear = cache.Upsert(new GearData
{
Name = "Thermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100, HeatPerformanceCurve = flatCurve
});
cache.FlushAsync().Wait();
var thermalData = cache.Get(thermalGear);
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
var ship = new Ship(items, null, hullItem, new EntitySettings());
var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f));
Assert.True(ship.TryEquip(gearItem, HardpointCell));
var equipped = ship.Equipment.Single(e => e.Data is GearData);
// Drive the entity's temperature to the design's optimal, at full durability (CreateInstance's default).
foreach (var cell in equipped.InsetShape.Coordinates)
ship.Temperature[cell.x, cell.y] = thermalData.OptimalTemperature;
equipped.UpdatePerformance();
Assert.Equal(1f, equipped.ThermalPerformance, 4);
Assert.Equal(1f, equipped.DurabilityPerformance, 4);
var stat = new PerformanceStat { Min = 0, Max = 1, QualityExponent = 0, HeatExponentMultiplier = 1, DurabilityExponentMultiplier = 1 };
var equippedResult = equipped.Evaluate(stat);
var unequippedResult = items.Evaluate(stat, gearItem);
Assert.Equal(unequippedResult, equippedResult, 5);
}
// Cut 0: the unequipped context has no heat, by name (UnequippedStatContext.HeatFactor is a fixed 1),
// regardless of the item's actual temperature or the stat's HeatExponentMultiplier. This is one of the two
// named disagreements the cut preserves rather than resolves -- the trade menu shows the un-discounted value
// on purpose. Mutation: have HeatFactor read a real thermal performance instead of the constant 1; this goes
// red because the item is equipped far off its optimal temperature while unequipped stays unchanged.
[Fact]
public void UnequippedIgnoresHeatRegardlessOfTemperature()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
var hullData = cache.GetByName<HullData>("Skiff");
var curve = new BezierCurve
{
Keys = new[] { new float4(0f, 0f, 0f, 0f), new float4(.5f, 1f, 0f, 0f), new float4(1f, 0f, 0f, 0f) }
};
var thermalGear = cache.Upsert(new GearData
{
Name = "ColdThermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100, HeatPerformanceCurve = curve
});
cache.FlushAsync().Wait();
var thermalData = cache.Get(thermalGear);
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
var ship = new Ship(items, null, hullItem, new EntitySettings());
var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f));
Assert.True(ship.TryEquip(gearItem, HardpointCell));
var equipped = ship.Equipment.Single(e => e.Data is GearData);
// Drive the item to the coldest end of its range -- far from optimal, ThermalPerformance -> 0.
foreach (var cell in equipped.InsetShape.Coordinates)
ship.Temperature[cell.x, cell.y] = thermalData.MinimumTemperature;
equipped.UpdatePerformance();
Assert.True(equipped.ThermalPerformance < 0.01f);
var stat = new PerformanceStat { Min = 0, Max = 1, QualityExponent = 0, HeatExponentMultiplier = 1 };
var equippedResult = equipped.Evaluate(stat);
var unequippedResult = items.Evaluate(stat, gearItem);
Assert.True(equippedResult < 0.01f); // heat tanks the equipped read
Assert.Equal(1f, unequippedResult, 3); // unequipped never reads heat at all
}
// Cut 0: ConsumableItemEffect.Evaluate substitutes progress-through-duration for heat -- applied
// unconditionally, not exponentiated by the stat's HeatExponentMultiplier the way EquippedItem's real heat
// performance is. That is the named "consumable substitutes progress for heat" disagreement, preserved
// exactly. Mutation: route consumable heat through pow(effectiveness, HeatExponentMultiplier) like the
// equipped path; this goes red because HeatExponentMultiplier is 0 here on purpose.
[Fact]
public void ConsumableEvaluateSubstitutesProgressForHeatUnconditionally()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
var hullData = cache.GetByName<HullData>("Skiff");
var ramp = new BezierCurve { Keys = new[] { new float4(0f, 0f, 0f, 0f), new float4(1f, 1f, 0f, 0f) } };
var consumableRef = cache.Upsert(new ConsumableItemData { Name = "Booster", Duration = 10, Effectiveness = ramp });
cache.FlushAsync().Wait();
var consumableData = cache.Get(consumableRef);
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
var ship = new Ship(items, null, hullItem, new EntitySettings());
// ItemManager.CreateInstance has no ConsumableItemData branch (it only special-cases EquippableItemData,
// falling back to CompoundCommodity otherwise); build the instance directly, as that method does for
// EquippableItem.
var consumableLot = items.CreateLot(consumableData, maker, .5f);
var consumableItem = new ConsumableItem { Data = cache.RefOf<ItemData>(consumableData), Lot = consumableLot };
var effect = new ConsumableItemEffect(consumableItem, ship);
effect.Update(5f); // half the duration elapsed -> progress .5 -> effectiveness .5 on a linear ramp
// HeatExponentMultiplier = 0: if consumable heat were exponentiated like the equipped path, progress
// would have no effect (pow(x, 0) == 1) and the result would be 1, not .5.
var stat = new PerformanceStat { Min = 0, Max = 1, QualityExponent = 0, HeatExponentMultiplier = 0 };
Assert.Equal(.5f, effect.Evaluate(stat), 3);
}
// F4: CreateLot(product) fills each of the design's roles from the product's own per-role spread
// (FactionProductData.Roles), not the design-wide default.
[Fact]

View file

@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Mutation tests for docs/stats-and-power-cut.md Cut 0 (one evaluation path).
Cut 0 collapses three disagreeing PerformanceStat.Evaluate bodies (ItemManager's unequipped read,
ConsumableItemEffect's, EquippedItem's) into one shared function, PerformanceStat.Evaluate(IStatContext),
fed by three IStatContext implementations. This script mutates each context's *identity* factor -- the
constant it returns instead of computing a term it structurally cannot have -- and asserts the named
test goes red for exactly that reason. A no-op control mutation proves the read/replace/restore path is
transparent.
Usage:
python tests/mutation_tests_stats_power_cut0.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/ItemManager.cs",
anchor=" public float HeatFactor(PerformanceStat stat) => 1f;",
mutated=" public float HeatFactor(PerformanceStat stat) => 1f;",
test="LoadoutTests.UnequippedIgnoresHeatRegardlessOfTemperature",
expect="green",
),
# --- Unequipped has no heat: UnequippedStatContext.HeatFactor is the fixed identity, not a real read ---
Mutation(
rule="unequipped must ignore heat (HeatFactor is the identity 1, not a computed value)",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=" public float HeatFactor(PerformanceStat stat) => 1f;",
mutated=" public float HeatFactor(PerformanceStat stat) => .5f;",
test="LoadoutTests.UnequippedIgnoresHeatRegardlessOfTemperature",
expect="red",
),
# --- Unequipped has no modifiers: ConstantModifier is the fixed identity 0 ---
Mutation(
rule="unequipped must ignore modifiers (ConstantModifier is the fixed identity 0)",
file="Assets/Scripts/ServerShared/ItemManager.cs",
anchor=" public float ConstantModifier(PerformanceStat stat) => 0f;",
mutated=" public float ConstantModifier(PerformanceStat stat) => 0.1f;",
test="LoadoutTests.UnequippedAgreesWithEquippedAtFullHealthAndOptimalTemperature",
expect="red",
),
# --- Consumable substitutes progress for heat unconditionally, not exponentiated by the stat's field ---
Mutation(
rule="consumable heat must not be exponentiated by HeatExponentMultiplier (that is the equipped shape)",
file="Assets/Scripts/ServerShared/Entity.cs",
anchor=(
" public float HeatFactor(PerformanceStat stat) =>\n"
" Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration);"
),
mutated=(
" public float HeatFactor(PerformanceStat stat) =>\n"
" pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), stat.HeatExponentMultiplier);"
),
test="LoadoutTests.ConsumableEvaluateSubstitutesProgressForHeatUnconditionally",
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:
path = REPO_ROOT / m.file
if m.file not in originals:
originals[m.file] = path.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)
return 1
print("\nAll mutation cases behaved as Cut 0's verification section demands.")
return 0
if __name__ == "__main__":
sys.exit(main())