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:
parent
e9b688de51
commit
67217ed97c
2 changed files with 286 additions and 0 deletions
|
|
@ -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]
|
||||
|
|
|
|||
151
tests/mutation_tests_stats_power_cut0.py
Normal file
151
tests/mutation_tests_stats_power_cut0.py
Normal 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())
|
||||
Loading…
Reference in a new issue