From 67217ed97c3f7a00d86e92e6bd3d8b74f8d99345 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 18 Sep 2026 16:19:33 +0200 Subject: [PATCH] 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 --- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 135 +++++++++++++++++ tests/mutation_tests_stats_power_cut0.py | 151 ++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 tests/mutation_tests_stats_power_cut0.py diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 6403120a5..cd6f246be 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -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("Maker")); + var hullData = cache.GetByName("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("Maker")); + var hullData = cache.GetByName("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("Maker")); + var hullData = cache.GetByName("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(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] diff --git a/tests/mutation_tests_stats_power_cut0.py b/tests/mutation_tests_stats_power_cut0.py new file mode 100644 index 000000000..ecae3f1cd --- /dev/null +++ b/tests/mutation_tests_stats_power_cut0.py @@ -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 + +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())