diff --git a/docs/stats-and-power-cut.md b/docs/stats-and-power-cut.md new file mode 100644 index 000000000..6dcf9cabc --- /dev/null +++ b/docs/stats-and-power-cut.md @@ -0,0 +1,851 @@ +# Stats and Power: Cut Map + +Date: 2026-09-18 + +Status: Imagination pass, cut map. Nothing here has landed. Ends are owned by +`docs/stats-and-power-target.md`; this document owns the means. + +Anchors are against `codex/item-provenance` HEAD `acb2fef9`. Claims marked +**(probe)** were measured headless against the real catalog `GameData/Aetheria.cc` +(read-only) with a scratch console referencing `Aetheria.Shared`, CultLib a temporary +detached worktree at `45c2f40`, built with `--artifacts-path` in scratch so this tree +got no `bin`/`obj` writes. Both are removed. The probe source is not kept; §1.6 records +what it did. + +**Scope: stats and power only.** Fire control is the next campaign and gets its own map. +§8 names the seam and what fire control will need from this authority. + +--- + +## 0. What is there now + +### 0.1 The stat, as it exists + +`PerformanceStat` (`ItemData.cs:575-622`) is a `[MessagePackObject]` **value**, not a +document. It has no `[CultDocument]`; every instance on disk lives nested two levels +inside a catalog record (`ItemData` → `BehaviorData` union member → the stat field). +Keys: `0 Min`, `1 Max`, `2 HeatExponentMultiplier`, `3 DurabilityExponentMultiplier`, +`4 QualityExponent`, `5 FromRole`. + +**(probe)** The catalog holds **348 reachable `PerformanceStat` instances** across 180 +documents: + +| | count | +|---|---| +| `HeatExponentMultiplier != 0` | 275 | +| `DurabilityExponentMultiplier != 0` | 145 | +| `QualityExponent != 0` | 193 | +| `FromRole` set | **0** | +| `Min != Max` (actually authored, not a stub) | 188 | + +By owner: `AutoWeaponData` 91, `ChargedWeaponData` 64, `GuidedWeaponData` 45, +`LauncherData` 40, `InstantWeaponData` 26, `RadiatorData` 25, `ReactorData` 20, +`SensorData` 12, `ThrusterData` 8, `AetherDriveData` 6, `EnergyDrawData` 3, +`HeatData`/`CapacitorData`/`ReflectorData` 2 each, `StatModifierData` 1, `GearData` 1 +(an `AudioStat`). + +**(probe)** Roles: 102 `CraftedItemData` designs, 51 equippable, 37 products. **0 +designs author roles; 0 products author role spreads.** `FromRole` therefore does +nothing today in the shipped catalog — `Lot.QualityForRole` (`Provenance.cs:73-79`) +falls back to `Lot.Quality` for every read. + +### 0.2 Three evaluation bodies that disagree + +| Site | Terms applied | Modifiers | +|---|---|---| +| `ItemManager.cs:75-90` `Evaluate(stat, item)` — **unequipped** | quality × durability | no | +| `Entity.cs:1177-1186` `ConsumableItemEffect.Evaluate` | quality × **consumable effectiveness** | no | +| `Entity.cs:1354-1371` `EquippedItem.Evaluate` | quality × durability × **heat** | yes | + +Three authorities for one number. The unequipped path in the trade menu and the +equipped path in the properties panel show different values for the same item on +purpose in no design document; it falls out of which function the caller reached for. + +### 0.3 The catalog object holds per-entity runtime state, and it leaks + +`PerformanceStat` carries two `[IgnoreMember]` dictionaries (`ItemData.cs:596-622`): + +``` +Dictionary> _scaleModifiers; +Dictionary> _constantModifiers; +``` + +`EquippedItem.Evaluate` calls `stat.GetScaleModifiers(Entity)` and +`GetConstantModifiers(Entity)` on **every evaluation** (`Entity.cs:1361,1365`), and both +accessors insert an entry for the entity when missing (`ItemData.cs:607-621`). +`StatModifier.RemoveModifier` (`StatModifier.cs:100-103`) removes only the inner +`[this]` key. **Nothing ever removes the outer `Entity` key.** + +So a shared catalog document holds a strong reference to every `Entity` that has ever +evaluated any stat, for the life of the process. Every destroyed ship, every zone left +behind, stays reachable from `Aetheria.cc`'s in-memory records. This is the target +doc's "authored catalog data that also stores runtime modifier state per entity" stated +as a defect rather than a smell. It is the strongest reason for the resolver, stronger +than the performance argument. + +Not measured: whether the leak is visible in a real session's memory profile. That is +an operator check (§7, O1), not a claim this map makes. + +### 0.4 Evaluation cost, measured, and why it is not the motive + +`Weapon.Execute` calls `UpdateStats()` unconditionally every tick (`Weapon.cs:127-131`), +so an **idle** weapon still evaluates 13 stats per tick (10 in `Weapon.UpdateStats` +`:113-125`, 3 in `InstantWeapon.UpdateStats` `:107-117`); a `ChargedWeapon` 16 +(`ChargedWeapon.cs:111-123`). + +**(probe)** Real generated loadouts (`LoadoutGenerator`, seed 1, the three hulls that +generate at HEAD): + +| hull | items | behaviors | evaluations/tick | +|---|---|---|---| +| `LonginusX` (Ship) | 14 | 24 | **78** | +| `Turret` | 9 | 15 | 20 | +| `Zenith` (Station) | 8 | 10 | 17 | + +78/tick is 4,680/s at 60 fps for one ship; a zone of 7 entities at the median is +~8,400/s. Each evaluation is three `pow` calls plus two nested dictionary lookups and +two dictionary iterations. That is around a millisecond per second per ship. **Caching +buys almost nothing here.** Say so plainly rather than selling the resolver on speed: +the resolver is bought by ownership, by the leak, and by making a power-supply term +cheap enough that adding it is not a regression. The operator's "we don't wanna pay +that cost for every stat evaluation" is satisfied because the power-supply source +changes at most once per tick, not because 78 became 12. + +### 0.5 The power path, end to end + +**Draw.** Nine call sites, all inside `ServerShared`, none outside it: + +| site | shape | +|---|---| +| `EnergyDraw.cs:46` | continuous or per-activation, `PerSecond` flag | +| `Thruster.cs:77` | continuous, scaled by input axis | +| `AetherDrive.cs:138` | continuous, three axes summed | +| `Radiator.cs:74` | continuous, scaled by temperature ratio | +| `ConstantWeapon.cs:87` | continuous while firing | +| `InstantWeapon.cs:98` | **instant**, per burst | +| `InstantWeapon.cs:178` | **instant**, per shot | +| `Sensor.cs:85` | **instant**, per ping | +| `Shield.cs:59` (+ `:54` `CanConsumeEnergy`) | **instant**, per hit taken | + +**Supply.** `Entity.TryConsumeEnergy` (`Entity.cs:858-888`): drain every charged +capacitor evenly in a `do/while`, then hand any remainder to every online reactor as +`Reactor.ConsumeEnergy` → `Draw +=`. It returns `onlineReactors > 0`. **Every draw +succeeds while one reactor is online**, which is the target doc's first complaint. + +`Reactor.Execute` (`Reactor.cs:85-147`), running last because `Reactor` is the only +`IOrderedBehavior` (`Order => 100`, `Reactor.cs:48`), subtracts its own generation from +the accumulated `Draw`, and: +- deficit → overload heat at `OverloadEfficiency`, then `Draw = 0`. Unmet demand is + never refused, only taxed as heat. +- surplus → fill capacitors evenly, then throttle. + +**The hidden priority the target names.** `EquippedItem.SortPosition` is set from the +**last** `IOrderedBehavior` found on the item (`Entity.cs:1347-1348`) and +`_orderedEquipment = Equipment.OrderBy(SortPosition)` (`Entity.cs:586,810`). Only +`Reactor` sets it, so reactor items sort last and **every other item sorts equal, in +equip order**. Whoever was equipped first drains the capacitors first. That is the +whole existing priority system: the order a player happened to fit their ship. + +Within an item, `BehaviorGroups` run in `Group` key order and the chain breaks on the +first `Execute` returning false (`Entity.cs:1404-1414`), so a failed draw silently +stops everything authored after it in the group. + +**Capacitors.** `Capacitor` (`Capacitor.cs:31-61`) is a bus capacitor: one shared pool, +charge added by the reactor's surplus and drained by anyone. There is no input-only +capacitor today. + +### 0.6 What persists, and where + +- **Catalog `Aetheria.cc`, read-only at runtime:** every `PerformanceStat`, every + `BehaviorData`, `CraftedItemData.Roles`, `FactionProductData.Roles`. A schema change + here is a migration with a census, like provenance Cut C. +- **Run store `run.cc`, deleted on death:** `EquippableItem.Durability` + (`ItemInstance.cs:53`), `OverrideShutdown` (`:54`), `Lot` and its `RoleFill`s + (`Provenance.cs:63-88`), `EntityPack.Temperature`/`Armor`/`Settings`/`WeaponGroups` + (`EntitySerializer.cs:179-188`). +- **Runtime-only, never saved:** `Capacitor.Charge`, `Reactor.Draw`/`CurrentLoadRatio`, + every `EquippedItem` derived performance value, the modifier dictionaries. +- **No migration mechanism exists.** `[CultDocument(name, "1")]` is identity only; + CultCache resolves by name and refuses ambiguity (`CultCache.cs:346-356`). The only + compatibility is slot-driven: an extra persisted `[Key(n)]` is ignored (reported in + `LastSchemaMigrationReports.IgnoredExtraSlots`), a missing one is defaulted, and any + type change is a hard refusal. So the only safe moves are **append a new key** or + **tombstone a key by comment and rewrite the catalog in one writable `Commit`**. +- `EquippableItem`'s next free MessagePack key is **12** (0,1 base; 2,9,10 tombstoned; + 7,8,11 live). + +### 0.7 Consumers outside `ServerShared` + +- `UI/Properties Panel/PropertiesPanel.cs:407-419` (equipped, evaluated), + `:553-569` (design, shows `Min`–`Max`), and `UI/Menu/TradeMenu.cs:273-284` (shows and + **sorts on `.Max` only**, no evaluation). Three near-duplicate + `RuntimeInspectable` → `float`/`int`/`PerformanceStat` switches that disagree. +- `PropertiesPanel.cs:400-402` and `:545-550` are the only places + `StatReference.Target`/`.Stat` are shown, as `SplitCamelCase()` strings. +- `UI/HUD/SchematicDisplay.cs:91-92,176-184` is the power HUD: `_reactor.Draw`, + `_capacitors.Sum(Charge)/Sum(Capacity)`. +- `Editor/CultCacheDrawers.cs:17-29` is the `InspectableType` dropdown that writes a + **bare type-name string** and silently resolves an unknown one to "None". It drives + `StatModifier.cs:23` `RequireBehavior` and `StatModifier.cs:136` `StatReference.Target`. +- `Gameplay/ActionGameManager.cs:899` is the only gameplay gate on power: + `GetBehavior() == null` refuses undock. +- Tests: `LoadoutTests.cs:491-533` `StatsReadTheLot`, `:554-571` + `EvaluateDurabilityExponentReadsLotQuality`, `:575-598` + `EquippedItemThermalExponentReadsLotQuality`; `IffAndCombatTests.cs:286-321` builds + `LockWeaponData` stats. `tests/mutation_tests.py:134-196` mutates the stat path. +- `tools/AetherDb` touches **no** stat or power type. Its `census` + (`Program.cs:37-108`) is the before/after invariant a catalog rewrite is verified + against, and `:85-90` is the role-authoring audit. +- There is **no** `CustomPropertyDrawer` for `PerformanceStat`; in CultCache Studio it + falls through to `inspector.DrawDefault`. +- There is **no** headless stat or power probe harness in the tree. + +--- + +## 0b. Identity, lifecycle, authority + +One row per persistent or runtime kind this campaign touches. No cut below is mapped +while a cell here is empty. + +| Kind | What names it | What happens to it over time | Who decides it | +|---|---|---|---| +| **A stat** (`PerformanceStat`) | Its field on a `BehaviorData`/`EquippableItemData`, reached by reflection; on disk, the `[Key(n)]` slot inside its owning catalog record | Authored in the catalog; **immutable at runtime**; one instance shared by every entity equipping that design | The catalog author, through CultCache Studio or a console over `Aetheria.Shared` | +| **A declared term** (`StatTerm`) | Its index in its stat's `Terms` list, plus its `Source` kind (and `Role` name for a quality term) | Authored with the stat; migrated once from the four fixed exponent fields; immutable at runtime | The catalog author. Validation refuses a role the design lacks, and refuses a power-supply term on a stat that decides a power request | +| **A modifier term** | The pair (modifier `Behavior` instance, target stat entry) in the owning entity's resolver | Attaches when the modifier's item is equipped **and** its behaviour executes; detaches on `Dispose` or a tick without execution (`StatModifier.cs:117-124`); never outlives the entity | The `StatModifier` behaviour, writing into the entity's resolver. **Not** the catalog object | +| **A resolver entry** | `(EquippedItem or ConsumableItemEffect, PerformanceStat reference)` | Created at equip / consumable start; invalidated when one of its term sources changes; destroyed at unequip / entity teardown | `StatResolver`, one per `Entity`, the sole owner of every resolved stat value | +| **A bus capacitor** (`Capacitor`) | Its `Behavior` instance on an equipped item | Runtime-only; charge starts at 0 each session and is never persisted; charged only by the bus, drained only by the bus | The `PowerBus`. No behaviour drains it directly | +| **An input capacitor** | Its `Behavior` instance on the instant-activation item that owns it | Runtime-only; filled continuously by the bus up to its rate; spent whole by one activation; never persisted | The bus fills; the owning behaviour spends. Two writers, disjoint signs, named | +| **A power tier** | `EquippableItem.PowerTier` (new run-store key 12) on the *unit*, not the design | A player choice; persists with the run; survives save/Continue; resets only with the run | The player, through the schematic/inventory UI. Nothing else writes it | +| **A power grant** | `(Entity, EquippedItem)` for one tick | Computed once per tick before equipment updates; overwritten next tick; never persisted | The `PowerBus`. Read-only everywhere else, including the HUD | +| **A lot's per-role quality** (`RoleFill`) | `(LotId, role name)` | Minted with the lot and **immutable**; an upgrade mints a *new* lot and repoints the item, which the resolver sees as a quality-source change | `ItemManager.CreateLot` (`ItemManager.cs:110-133`), rolling the product's `ProductRole` spread | +| **A design role** (`ItemRole`) | Its `Name` string on `CraftedItemData.Roles` | Catalog-authored; adding one to a shipped design changes what existing lots fall back on (`QualityForRole` returns `Lot.Quality` for an unfilled role) | The catalog author. Agents generate, the operator reviews | + +Two rows deserve their consequence spelled out. + +- **A stat is shared; a resolved value is not.** That single sentence is the whole + ownership fix. Everything per-entity moves off the catalog object into the resolver. +- **A lot is immutable, so quality is a rare invalidation.** An upgrade is a lot swap, + not a mutation, so the resolver never needs to watch a quality number — only the item's + `Lot` field. + +--- + +## 1. Authority maps + +### 1.1 Stat values + +- **Owner:** `StatResolver`, one per `Entity`, constructed with it. +- **Inputs:** the stat's `Min`/`Max` and declared `Terms`; the current value of each + term's source (heat, durability, quality-for-role, power supply, consumable + progress); the modifier terms attached to that entry. +- **Outputs:** one `float` per `(item, stat)`, read by every behaviour and by the UI. +- **Derived state:** every `Weapon.Damage`-style cached field on a behaviour becomes a + read of the resolver, not an independent computation. +- **Forbidden writers:** `PerformanceStat` may hold no per-entity state at all — the + two dictionaries and their four accessors are deleted, not hidden. `ItemManager` and + `ConsumableItemEffect` stop computing stat values. No behaviour writes into another + behaviour's stat. +- **Shared paths:** equipped items, consumable effects, the properties panel, the trade + menu and any future headless balance harness resolve through the same entry point. The + unequipped case (trade menu, an item on the floor) is the one path with no entity; + it resolves against a null-entity context that has no heat, no power and no modifiers, + and it says so in its own name rather than being a third quiet dialect. +- **Deletion line:** `ItemData.cs:580-594` (the four fixed exponent fields), + `ItemData.cs:596-622` (the dictionaries), `ItemManager.cs:75-90`, `Entity.cs:1177-1186`, + `Entity.cs:1354-1371`, `StatModifier.cs:61-103` (the reflection target search and the + writes into the catalog object) all go before the resolver's first read lands. + +### 1.2 Power + +- **Owner:** `PowerBus`, one per `Entity`, stepped once per tick from `Entity.Update` + **before** `_orderedEquipment` updates. +- **Inputs:** each reactor's resolved generation for the tick; each bus capacitor's + stored charge; each consumer's request, which is a resolved stat multiplied by a + behaviour-supplied scalar in [0,1]; each item's `PowerTier`. +- **Outputs:** a grant ratio per equipped item in [0,1]; the reactor load ratio; the + overload and throttling heat the reactor adds. +- **Derived state:** `EquippedItem.PowerSupply` (the grant ratio) is display-only and + stat-source-only. `Capacitor.Charge` is written only by the bus. `Reactor.Draw` + becomes a **reported** total, not a sink for unmet demand. +- **Forbidden writers:** `Entity.TryConsumeEnergy` and `Entity.CanConsumeEnergy` are + deleted, so no behaviour can spend energy on its own. `Reactor.ConsumeEnergy` is + deleted. `Capacitor.AddCharge` becomes internal to the bus and the owning behaviour. + Behaviour execution order is no longer allowed to decide who gets power — it decides + nothing about power at all. +- **Shared paths:** player firing, AI firing, thrust, radiators, shields and sensor + pings all request through the same path. There is no "the player's weapon is special" + branch; fire control will inherit this (§8). +- **Deletion line:** `Entity.cs:851-888` and `Reactor.cs:80-83,90-141` are cut before + the bus's first grant is computed. + +### 1.3 Priority tiers + +- **Owner:** `PowerBus`'s allocation pass. +- **Inputs:** the per-item tier and the per-item request. +- **Outputs:** the grants. +- **Derived state:** nothing. A tier is a stored player choice, not a derivation. +- **Forbidden writers:** `EquippedItem.SortPosition` must no longer influence who is fed + — and once the bus exists, `IOrderedBehavior` has exactly one remaining job (running + the reactor's own heat accounting last) which the bus subsumes. `IOrderedBehavior`, + `Reactor.Order` and the `OrderBy(SortPosition)` sorts are candidates for deletion in + the same cut; the map assumes they go, and §7 O3 is where the operator confirms + nothing else wanted that ordering. +- **Shared paths:** the tier is set from the schematic UI and read by the bus. The AI + sets tiers through the same field or not at all. +- **Deletion line:** `Entity.cs:1347-1348`, `Entity.cs:586`, `Entity.cs:810`, + `Reactor.cs:48`, `Behaviors.cs:127-130`. + +--- + +## 2. The target shape, named + +Types this campaign creates. Bodies are not transcribed; what matters is what each owns +and what must die under its own mutation. + +**`StatSource`** — an enum: `Heat`, `Durability`, `Quality`, `PowerSupply`, +`ConsumableProgress`. Closed. Adding a member is a schema change with a census. + +**`StatTerm`** — `[MessagePackObject]`: `Key(0) StatSource Source`, `Key(1) float +Exponent`, `Key(2) string Role`. `Role` is meaningful only for `Quality`; validation +refuses it elsewhere rather than ignoring it. + +**`PerformanceStat`** — keeps `Key(0) Min`, `Key(1) Max`; gains `Key(6) List +Terms`; **tombstones keys 2, 3, 4, 5**. The name stays: renaming it would churn 98 call +sites and buy no invariant. + +**`StatResolver`** — per entity. Owns `(item, stat) → value`. Exposes resolve, +invalidate-by-source, attach-modifier, detach-modifier, and dispose. The only type that +may hold a resolved value. + +**`StatContext`** — what a resolver entry reads: the item's heat performance, durability +ratio, lot, power supply and consumable progress. Implemented by `EquippedItem`, by +`ConsumableItemEffect`, and by a bare unequipped context whose heat, power and modifier +sets are empty. This is the seam that makes the three dialects one function. + +**`PowerBus`** — per entity. Owns supply, demand, tiers, grants, and every capacitor +write. + +**`IPowerConsumer`** — implemented by a behaviour that draws: it names *which* +`PerformanceStat` is its request and supplies a scalar in [0,1] for this tick. It does +**not** hand the bus a number it computed itself. That is deliberate: it gives the +validator an exact, static set of request stats, so "a stat deciding a power request may +not depend on power supply" is checkable at catalog load rather than by discipline. A +consumer whose draw is genuinely multi-term (`AetherDrive`, three axes) expresses it as +one stat times a computed scalar. + +**`InputCapacitor`** — a behaviour on an instant-activation item. The bus fills it at a +rate; one activation spends a whole shot's worth or does not fire. Its capacity and rate +default from the item's existing `Energy` and `Cooldown` stats so that no catalog +authoring is required for the first cut (§7 Q4). + +**`StatValidation`** — the loud refusal. Runs at catalog load, at Studio save, and at +equip. Names the items and the stat. + +--- + +## 3. Cuts + +Deletes come first inside each cut, and subtraction cuts are kept separate from +behaviour cuts. + +### Cut 0. One evaluation path + +- **Repo/branch:** `Aetheria` `codex/item-provenance` from `acb2fef9`. Depends on + nothing. +- **First:** capture `tools/AetherDb` `census`, `factions`, `dangling`, `station-fit`, + `loadout 1`, `hardpoint-fit` into scratch, byte-for-byte, same method both sides. + Record the `dotnet test` baseline at HEAD (the headless map recorded 32 passed / 2 + failed at `1e647953`; re-measure, do not assume). +- **Deletes first:** + - `ItemManager.cs:75-90` (16 lines) — the unequipped body. + - `Entity.cs:1177-1186` (10) — the consumable body. + - `Entity.cs:1354-1371` (18) — the equipped body. +- **Adds:** one evaluation function taking a `StatContext`, plus the three context + implementations. `EquippedItem`, `ConsumableItemEffect` and the unequipped case + implement it; each supplies only the sources it has. +- **Per-file changes:** + - `Behaviors.cs:60` `Evaluate(stat) => Item?.Evaluate(stat) ?? Consumable.Evaluate(stat)` + becomes a resolve against the owning context. **The method name `Evaluate` and its + signature stay**, so all 81 behaviour call sites are untouched. This is the one place + in the campaign where preserving a name is the right call: it is a genuine facade, + not a compatibility shim, and it delegates rather than deciding. + - `PropertiesPanel.cs:529` and `:490` both route through the new contexts. + - `TradeMenu.cs:273-284` keeps showing `.Max` **in this cut** — changing what the trade + menu shows is a design change, and it belongs in Cut 8 with the operator's ruling + (§7 Q6). +- **Authority map:** §1.1, minus the resolver and minus the modifier move. This cut + makes one function own the arithmetic; it does not yet move where modifier state + lives. +- **Verification:** + - builds: `dotnet build Aetheria.Shared/Aetheria.Shared.csproj`, + `dotnet build tools/AetherDb`, each with `-p:CultLibRoot=`. + - tests: `LoadoutTests.StatsReadTheLot` pins that a role-named stat reads the role and + an unnamed one reads workmanship. + `EvaluateDurabilityExponentReadsLotQuality` pins the durability path. + **New:** a test pinning that the *unequipped* and the *equipped-at-full-health-and- + optimal-temperature* paths now agree, which they do not at HEAD. That test is the + cut. + - negative: `rg "public float Evaluate\(PerformanceStat" Assets/` returns exactly one + hit. Verified against legitimate names: `BezierCurve.Evaluate(float)`, + `ExponentialCurve.Evaluate(float)`, `Environment.Evaluate(float2,…)` and + `ZoneData.Evaluate(float)` all differ in parameter type, so the pattern does not + collide. + - mutation: restore heat to the unequipped path; the new agreement test must go red + naming it. + - operator: none. No visible change is intended, and if one appears it is a defect. + +### Cut 1. Terms replace the four fixed exponents + +- **Depends on:** Cut 0. +- **First:** a read-only probe against a **copy** of `Aetheria.cc` answering: does the + catalog still open read-only with keys 2-5 gone, and how many records report + `IgnoredExtraSlots`? Record the file's sha1 and byte length. This is provenance Cut C's + method (`docs/item-provenance-cut.md:179-196`) and it is not optional. +- **Deletes first:** `ItemData.cs:580-594` — `HeatExponentMultiplier`, + `DurabilityExponentMultiplier`, `QualityExponent`, `FromRole` (15 lines), replaced by a + tombstone comment in the style of `ItemData.cs:281-282`: *keys 2, 3, 4 and 5 belonged + to fixed exponent fields now expressed as terms; do not reuse them.* +- **Adds:** `StatSource`, `StatTerm`, `PerformanceStat.Terms` at `Key(6)`. +- **Per-file changes:** the single evaluation function from Cut 0 walks `Terms` instead + of the four fields. `ConsumableItemEffect`'s `effectiveness` becomes a + `ConsumableProgress` term rather than a hard-coded factor, which removes the last + reason for that path to be different. +- **The migration.** A scratch console, **deliberately not committed**, opening + `AetheriaStores.Open(..., catalogWritable: true)` and re-upserting every stored + `ItemData` under its own key in **one `Commit`**. Precondition: Unity and CultCache + Studio closed. The committed binary is the artifact; the code is not kept. Per stat, + the rewrite is mechanical: + - `HeatExponentMultiplier != 0` → a `Heat` term with that exponent (275 stats). + - `DurabilityExponentMultiplier != 0` → a `Durability` term (145). + - `QualityExponent != 0` → a `Quality` term with `Role = FromRole` (193; `FromRole` is + unset on all 348, so every migrated quality term is roleless). + - A zero multiplier becomes **no term**, not a term with exponent 0. The difference is + the point: a stat declares only the terms it uses. +- **Authority map:** unchanged owner (the catalog author); what changes is that the + stat's dependency set becomes data the validator can read instead of four fields that + are always present and usually zero. +- **Verification:** + - Reopen shows **zero** reports with `IgnoredExtraSlots`, and the expected byte delta. + - `AetherDb` `census`, `factions`, `dangling`, `station-fit`, `loadout 1`, + `hardpoint-fit` captured before and after are **byte-identical**. None of them reads a + stat value, so any difference is a defect. + - A reflection dump of all 180 records before and after, comparing every *evaluated* + stat at quality 0.5, durability 1.0 and optimal temperature: identical to float + tolerance. This is the real proof; `census` only proves nothing unrelated moved. + - tests: `StatsReadTheLot` and `EvaluateDurabilityExponentReadsLotQuality` rewritten to + build `Terms`, still pinning the same rules. + - negative: `rg "HeatExponentMultiplier|DurabilityExponentMultiplier|QualityExponent|FromRole" Assets/ tools/ tests/` + returns only the tombstone comment. `FromRole` is checked separately because + `ProductRole.Role` and `RoleFill.Role` are legitimate and must not be swept. + - Unity batchmode, editor closed, no `error CS` (§6). + - Two commits, as Cut C landed: code, then the catalog rewrite. + +### Cut 2. The resolver owns every value + +- **Depends on:** Cut 1. +- **Deletes first:** + - `ItemData.cs:596-622` (27 lines) — both dictionaries and all four accessors. **The + leak in §0.3 dies here, structurally, not by cleanup.** + - `StatModifier.cs:61-86` (26) — the reflection target search that matches by type + name and grabs `PerformanceStat` references out of other designs. + - `StatModifier.cs:88-103` — the writes into the catalog object. +- **Adds:** `StatResolver`; `StatValidation` for self-dependency and modifier-chain + cycles; `StatModifier` rewritten to attach and detach a term in the entity's resolver. +- **Per-file changes:** + - `Entity.cs` constructs a resolver with the entity and disposes it with it. + - `EquippedItem` signals `Durability` and `Heat` invalidation from `UpdatePerformance` + (`Entity.cs:1377-1393`), and `Quality` invalidation when its `Lot` changes. + - `StatReference` (`StatModifier.cs:132-140`) keeps its `(type name, field name)` shape + but is **resolved once at catalog load into a concrete field, and fails loudly when + it does not resolve**. Today an unresolvable reference is a silent no-op + (`StatModifier.cs:63-65` leaves `_stats` null and `ApplyModifier` would throw on + first use). §7 Q2 is whether the operator wants a stronger identity than a name pair. +- **Authority map:** §1.1 in full. +- **Verification:** + - tests: a test equipping the same design on **two** entities, attaching a modifier on + one, and asserting the other's resolved value is unchanged. At HEAD this passes by + accident because the dictionary is keyed by entity; after the cut it passes because + there is nothing shared to get wrong. Mutation: make the resolver static; it goes red. + - tests: a test that unequipping and destroying an entity leaves no reference to it + reachable from the catalog record — a `WeakReference` to the entity, a forced + collection, and an assertion that it is dead. **This test fails at HEAD.** It is the + cut's headline and the only test here that proves the leak is gone. + - tests: a stat whose term chain reaches itself through a modifier is refused at equip, + naming both items and the stat. + - negative: `rg "GetScaleModifiers|GetConstantModifiers"` returns nothing. + - operator: O1 — play a session, open a few zones, kill some ships, and confirm memory + does not climb the way it did. + +### Cut 3. The power bus replaces `TryConsumeEnergy` + +- **Depends on:** Cut 2 (the bus's grant is a resolver source). +- **Deletes first:** + - `Entity.cs:851-856` `CanConsumeEnergy` (6 lines, one caller: `Shield.cs:54`). + - `Entity.cs:858-888` `TryConsumeEnergy` (31). + - `Reactor.cs:80-83` `ConsumeEnergy` (4). + - `Reactor.cs:90-141` — the deficit and capacitor-fill branches as a *demand* + mechanism (~52 lines; the overload-heat and throttling arithmetic survives, moved + under the bus's numbers). +- **Adds:** `PowerBus`; `IPowerConsumer` on the nine draw sites; `EquippedItem.PowerSupply`. +- **Per-file changes:** each of the nine sites in §0.5 stops calling a spend and starts + declaring a request. The five continuous ones (`EnergyDraw`, `Thruster`, `AetherDrive`, + `Radiator`, `ConstantWeapon`) convert directly. The four instant ones + (`InstantWeapon.cs:98,178`, `Sensor.cs:85`, `Shield.cs:59`) are **out of scope for this + cut** — they keep a direct capacitor spend, marked with a comment naming Cut 4 as their + owner, and they are the one temporary split this campaign accepts. That split is named, + bounded to one cut, and Cut 4's deletion line closes it. +- **Authority map:** §1.2, with the instant sites listed under forbidden writers as + *not yet*, which is the honest word. +- **Verification:** + - tests: a ship whose requests exceed generation gets a total grant equal to + generation plus available capacitor charge, and **not** more. At HEAD the equivalent + assertion is unwritable, because every draw succeeds. + - tests: a refused request produces no effect at all — not a partial one, and not an + overload-heat receipt. Mutation: make the bus grant the full request and heat the + reactor; it goes red. + - tests: two ships with identical loadouts fitted in **opposite equip order** get + identical grants. That is the death of the hidden priority, and it fails at HEAD. + - negative: `rg "TryConsumeEnergy|CanConsumeEnergy|ConsumeEnergy"` returns nothing. + - operator: O2 — fly with a weak reactor and confirm that things starve visibly and + legibly rather than cooking the reactor. + +### Cut 4. All draw is continuous + +- **Depends on:** Cut 3. +- **Deletes first:** the four direct capacitor spends left standing by Cut 3 + (`InstantWeapon.cs:98,178`, `Sensor.cs:85`, `Shield.cs:59`), and with them the last + path by which anything but the bus touches stored charge. +- **Adds:** `InputCapacitor`, with capacity and rate defaulting from the owning item's + `Energy` and `Cooldown` stats (§7 Q4). +- **Per-file changes:** firing becomes a check against the input capacitor's charge. + `ChargedWeapon` (`ChargedWeapon.cs:125-152`) is the interesting case: its charge cycle + already is a buffer, and `ChargeEnergy` should become the fill rate rather than a + separate concept. `Shield.cs:57-61` is the odd one out: a shield hit is not an + activation the player chose, so its "instant" draw is really a continuous reserve. Name + it as such rather than giving it an activation buffer. +- **Verification:** + - tests: a weapon whose input capacitor is partly filled does **not** fire a partial + shot. The operator's ruling — "no item receives a fraction of a shot" — is the rule + this test pins. + - tests: halving the grant halves the refill rate and therefore the sustained rate of + fire, without changing damage per shot. + - negative: `rg "AddCharge" Assets/` hits only the bus and `InputCapacitor`. + - operator: O4 — the feel of a weapon that stutters under brownout instead of firing + at full rate and cooking the ship. + +### Cut 5. Priority tiers + +- **Depends on:** Cut 3. +- **Deletes first:** `Entity.cs:1347-1348` (the `SortPosition` assignment), + `Entity.cs:586` and `:810` (the `OrderBy(SortPosition)` sorts), `Reactor.cs:48` + (`Order => 100`), `Behaviors.cs:127-130` (`IOrderedBehavior`) — **subject to O3**. +- **Adds:** `EquippableItem.PowerTier` at `Key(12)`; the tiered allocation pass; + a tier control in the schematic UI. +- **Per-file changes:** `EntitySerializer` needs nothing — the field rides inside + `EquippableItem`, which is already packed. Old run stores default the field, and death + clears the run anyway. +- **Authority map:** §1.3. +- **Verification:** + - tests: with generation short of total demand, tier 0 is satisfied in full before tier + 1 receives anything, and tier 1 divides what is left. + - tests: within a tier, two consumers with equal requests get equal grants; with + unequal requests, proportional ones. + - tests: a tier change on an equipped item takes effect on the next tick with no + re-equip. + - negative: `rg "SortPosition|IOrderedBehavior"` returns nothing. + - operator: O3 — confirm nothing depended on reactors executing last for a reason this + map did not find. O5 — the tier UI is playable. + +### Cut 6. The power supply term, brownout, and the request-independence rule + +- **Depends on:** Cuts 2, 3, 5. +- **Deletes first:** nothing. This is the one purely additive cut, and it buys the + operator's named capability: a continuous consumer that degrades instead of stopping. +- **Adds:** `StatSource.PowerSupply` handling in the resolver; the validation rule that a + request stat may not depend on power supply, directly or through a modifier chain. +- **Per-file changes:** the resolver invalidates `PowerSupply` entries once per tick from + the bus. The validator runs at catalog load, at Studio save, and at equip; because + `IPowerConsumer` names its request stat rather than computing a number, the set of + request stats is exactly enumerable from the catalog, and the check is static. +- **Verification:** + - tests: a thruster at half grant produces less thrust, by the authored exponent. + - tests: a catalog holding a thruster whose `EnergyUsage` carries a `PowerSupply` term + is **refused at load**, naming the item and the stat. Mutation: drop the rule; the + test goes red. + - tests: the same refusal through a modifier chain, at equip time, naming both items. + - tests: the resolver recomputes a power-termed stat at most once per tick. This is the + operator's "don't pay that cost for every stat evaluation" stated as an assertion. + - operator: O6 — brownout reads as degradation, not as breakage. + +### Cut 7. Roles authored + +- **Depends on:** Cut 1 (terms carry the role). +- **Deletes first:** nothing. This is a content cut, and it is where the campaign stops + being code. +- **Adds:** roles on designs, per-role means and deviations on products, and + `Role`-carrying quality terms on the stats that should read a part rather than + workmanship. Agents generate; the operator reviews. Written through the catalog's own + types — AetherDb or a scratch console over `Aetheria.Shared` — never a hand-copied + schema. +- **Note the sharp edge:** adding a role to a shipped design changes nothing for existing + lots, because `QualityForRole` falls back to `Lot.Quality` for an unfilled role. But a + *newly minted* lot from a product with no spread for that role rolls + `new ProductRole()` — mean .5, and `ProductRole`'s default deviation + (`FactionProduct.cs:33-48`). Authoring a role on a design without authoring the + matching product spreads silently pulls every maker toward average. The census + (`AetherDb Program.cs:85-90`) is where that is caught, and the two must land together. +- **Verification:** + - `AetherDb census` reports every design's roles filled by every product that sells it; + zero designs with roles and no matching product spread. + - `AetherDb loadout 1` before and after: the failure count must not rise. + - operator: O7 — the review pass on generated role content. Only the operator can do + this. + +### Cut 8. UI reconciliation + +- **Depends on:** Cuts 2, 3, 5. +- **Deletes first:** `PropertiesPanel.cs:553-569` and `TradeMenu.cs:273-284`, the two + divergent copies of the `RuntimeInspectable` switch (~35 lines together), collapsed + into the survivor at `PropertiesPanel.cs:407-419`. +- **Per-file changes:** + - `PropertiesPanel.cs:400-402` and `:545-550` stop building a label from + `SplitCamelCase()`d type and field names and use the identity the resolver already + has. + - `SchematicDisplay.cs:176-184` reads the bus's supply and grants instead of + `_reactor.Draw` and a hand-summed capacitor pool, and gains the per-item tier and + grant readout — which is the whole point of tiers being a player choice. + - `Editor/CultCacheDrawers.cs:17-29`: the type-name dropdown keeps writing a name, but + Cut 2's load-time resolution means an unresolvable one now fails loudly instead of + reading as "None". +- **Verification:** + - operator only, and it is the largest operator surface in the campaign: the properties + panel, the trade menu, the schematic HUD, the tier control, and a CultCache Studio + click-through over a stat with terms. §7 O8. + +--- + +## 4. Subtraction ledger, estimated + +Line counts for deletes are exact against `acb2fef9`; adds are estimates and should be +compared with what lands, in the postmortem. + +| Cut | Removed | Added (est.) | Net | Structure | +|---|---|---|---|---| +| 0 | 44 | ~45 | ~+1 | 3 evaluation authorities → 1 | +| 1 | 15 | ~40 | ~+25 | 4 fixed fields → a declared term list; 4 catalog keys retired; **catalog rewritten** | +| 2 | 53 | ~180 | ~+127 | per-entity state leaves the catalog object; a leak dies; validation appears | +| 3 | 93 | ~200 | ~+107 | 9 ad-hoc spends → 1 bus; 2 public entity methods deleted | +| 4 | ~30 | ~90 | ~+60 | the last direct capacitor writers die | +| 5 | ~12 | ~110 | ~+98 | `IOrderedBehavior` deleted; 1 new run-store key | +| 6 | 0 | ~120 | ~+120 | the only purely additive cut; buys brownout and the request rule | +| 7 | 0 | ~0 code | 0 | content; catalog data only | +| 8 | ~35 | ~20 | ~-15 | 3 stat renderers → 1 | +| **Total** | **~282** | **~805** | **~+523** | | + +This campaign is net additive by roughly 500 lines and it should be. What it buys, named +against the "every new abstraction must name its owner" rule: + +- `StatResolver` — owner of every resolved value. Replaces per-entity state living on a + shared catalog document, and the entity leak that shape produced. +- `PowerBus` — owner of allocation. Replaces nine independent spend paths and an implicit + priority nobody authored. +- `StatTerm` — replaces four fixed fields with a declared set, which is what makes + validation possible at all. +- `InputCapacitor` — buys the operator's "all power draw is continuous" ruling. + +Targets, packages, dependencies, formats: **none added, none removed**. One MessagePack +key added to `EquippableItem` (run store), one added and four retired on +`PerformanceStat` (catalog). + +## 5. Build budget + +- Packages: `Aetheria.Shared` (netstandard2.1), `tools/AetherDb` and + `tests/Aetheria.Shared.Tests` (net10.0), Debug only. No new project, target, package or + store. +- CultLib at `45c2f40`, unmodified, as a detached worktree passed with + `-p:CultLibRoot=`, removed after. +- Headless commands use `--artifacts-path ` while another agent reads the tree. +- **Build host is this Windows workstation, and it is also the target** for every + headless check. `Assembly-CSharp` is proven only by Unity batchmode, editor closed + (§6), which is also this host. +- The catalog rewrite in Cut 1 is the one irreversible artifact. It runs once, with Unity + and CultCache Studio closed, against a backed-up `Aetheria.cc`. + +## 6. Unity batchmode recipe + +Per cut that touches `Assembly-CSharp` (Cut 8, and any cut whose signature change reaches +`Assets/Scripts/UI` or `Assets/Scripts/Gameplay` — Cuts 0, 2, 3, 5 all do): + +1. Confirm no editor: `Get-Process Unity`, and `Test-Path F:\Projects\Aetheria\Temp\UnityLockfile`. + If either says yes, stop and ask. Never two batchmode runs at once. +2. `Start-Process "C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" -ArgumentList '-batchmode -nographics -quit -projectPath F:\Projects\Aetheria -logFile \unity-cut-N.log' -PassThru` +3. `Select-String -Path -Pattern "error CS|Exiting batchmode"`. + +There is no stat or power probe harness in the tree and this map does not add one. The +NUnit suite reaches everything in `ServerShared`, which is where all of this lives; +batchmode is only the compile check for the UI edits. + +## 7. Operator forks + +Most blocking first. + +**Q1 — Does the campaign land before or after fire control?** +`docs/three-gates-scope.md` puts fire control at item 4 of the shipping list and +`docs/headless-playground-cut.md` maps it as Cut 2. Fire control will roll hits over +weapon and targeting stats, and those rolls want a resolver that can hand out a value +without re-deriving it, and a power model where a weapon can be starved. +**A:** stats and power first, fire control leans on the finished authority. +**B:** fire control first, and it builds its rolls on `PerformanceStat` as it is today, +then migrates. +**Recommended: A**, because B means fire control is written twice — once against three +disagreeing evaluation bodies and a stat that leaks entities, then again. But A delays +the shipping list's item 4, and that is the operator's call, not this map's. + +**Q2 — How does a modifier name the stat it targets?** +Today it is `(type name string, field name string)`, matched by reflection, and an +unresolvable pair is a silent no-op that would throw on first use. Exactly **one** +modifier is authored in the whole catalog (**probe**: `CapacitorData.Capacity`, +Multiplier, and it does resolve). +**A:** keep the name pair, resolve it once at catalog load, fail loudly. Studio's +existing `InspectableType` dropdown (`CultCacheDrawers.cs:17-29`) gains a second popup +for the field so a name is never typed. +**B:** give every stat a stable authored id and reference that. +**Recommended: A.** One authored instance does not justify a new identity scheme, and B +is a registry by another name — the thing doctrine says not to reach for. A converts the +silent failure into a loud one, which is what the target doc actually asked for. If role +authoring (Cut 7) multiplies modifiers into the dozens, revisit. + +**Q3 — Heat changes every tick. What does "recompute only when a source changes" mean +for it?** +275 of 348 stats carry a heat term, and `ThermalPerformance` is recomputed every tick in +`UpdatePerformance` (`Entity.cs:1377`). A literal dirty-flag on heat means recomputing +almost everything every tick anyway, which is exactly what happens now. +**A:** accept it. Heat-termed stats recompute per tick; the resolver's win is ownership +and the fact that power supply, durability and quality — the sources that would otherwise +multiply the cost — are cheap. Measured cost at HEAD is ~4,680 evaluations/s for the +heaviest generated ship (**probe**), around a millisecond per second. +**B:** quantize `ThermalPerformance` to a step (1/256, say) and invalidate only when it +crosses one. Turns a per-tick recompute into a rare one at the price of a visible +quantization in stat values and HUD bars. +**Recommended: A.** The measurement says the cost is not real, and B pays gameplay +visibility for a performance win nobody needs. B stays available if a future zone is +much denser than 7 entities. This is the fork most likely to be wrong, because the probe +measured three hulls and a real fight may be heavier. + +**Q4 — Where does an input capacitor's size come from?** +Every instant-activation item needs one, and **0 are authored**. +**A:** derive it — capacity from the item's `Energy` stat, rate from `Energy / Cooldown` — +so Cut 4 needs no catalog authoring and a ship at rest behaves as it does today. +**B:** author `InputCapacitorData` on every instant item, which is a catalog content pass +across 15 weapon-bearing designs before Cut 4 can land. +**C:** A now, with an authored override that B can fill in per design later. +**Recommended: C.** A alone makes burst capacity and sustained rate the same authored +number, which flattens a real design lever — a weapon that holds three shots and refills +slowly is a different weapon from one that holds one and refills fast. C keeps the cut +landable and keeps the lever. + +**Q5 — How many priority tiers, and who authors the default?** +The target says critical first, each lower tier dividing what is left. It does not say +how many, or what an item defaults to. +**A:** three (critical / normal / auxiliary), defaulted per hardpoint type. +**B:** four or five, defaulted per behaviour kind (life support and reactor critical, +thrust normal, weapons normal, sensors auxiliary). +**C:** a free integer, everything defaulting to the same tier, and the player sorts it. +**Recommended: B.** A default that is already sensible is what makes the tier UI an +option rather than a chore, and per-behaviour is where the knowledge is. C makes every +new ship a configuration task before it flies. + +**Q6 — What should the trade menu show for a stat?** +It shows and sorts on `.Max` alone (`TradeMenu.cs:273-284`) — the value at perfect +quality, perfect durability and optimal temperature, which no item ever has. The +properties panel shows the evaluated value for an equipped item and `Min`–`Max` for a +design. +**A:** show the evaluated value for the unit on offer, which is what the player is buying. +**B:** keep `.Max` as a comparable ceiling and add the evaluated value beside it. +**Recommended: A** for what the player reads, **B** for what the column sorts on — a +ceiling is the honest sort key when comparing designs, and the evaluated number is the +honest display when comparing units. This is a feel question and the operator should see +it before it lands. + +**Q7 — Does the heat/negent work want anything from this campaign?** +`docs/stats-and-power-target.md`'s negentropy section and +`docs/negent-weapons-concept.md` both rest on "cooling is a change to a condition, not an +effect on a stat", which this campaign's `StatSource.Heat` term delivers unchanged. The +one thing negent needs that is not here is a **consumable charge gating the cooling**, and +`StatSource.ConsumableProgress` (Cut 1) is the term for it. +**A:** nothing more. Negent is content against this authority. +**B:** add a negent-shaped source now. +**Recommended: A.** The concept doc's own claim is "almost nothing new, which is the +point"; adding a source ahead of a single authored item would be inventing a mechanism to +match a document. + +### Operator-only checks + +- **O1 (Cut 2)** — play a session across several zones with kills, and confirm memory does + not climb the way the leak in §0.3 predicts. The `WeakReference` test proves the + reference is gone; only a played session proves it mattered. +- **O2 (Cut 3)** — fly with a reactor too small for the loadout. Starvation should be + legible. +- **O3 (Cut 5)** — confirm nothing wanted reactors to execute last for a reason this map + did not find, before `IOrderedBehavior` is deleted. +- **O4 (Cut 4)** — the feel of a weapon refilling under brownout. +- **O5 (Cut 5)** — the tier control is playable from the schematic screen. +- **O6 (Cut 6)** — brownout reads as degradation. +- **O7 (Cut 7)** — review the agent-generated role content. Nobody else can. +- **O8 (Cut 8)** — properties panel, trade menu, schematic HUD, tier control, and a + CultCache Studio click-through over a stat with terms. + +--- + +## 8. The seam with fire control + +Fire control is the next campaign and owns its own map. Where the two touch: + +**What fire control will find already built.** +- One resolved value per `(item, stat)`, from one owner, with no per-entity state on the + catalog object. A roll over weapon and targeting stats reads the resolver; it does not + evaluate. +- Weapon and targeting stats that can carry a `PowerSupply` term, so a starved targeting + system rolls worse rather than switching off. That is the brownout ruling applied to + accuracy, and it is available the moment Cut 6 lands. +- An input capacitor per instant weapon, which means "can this weapon fire right now" is + a question with a cheap, local, authoritative answer — no bus round trip inside the + roll. +- A power request path shared by player and AI, so an AI ship cannot draw in a way a + player ship cannot. + +**What fire control must own, and this map does not touch.** +- The hit roll itself, the target-data thresholds, subsystem selection, and + `DamageSchematic` moving out of `Gameplay/EntityInstance.cs` + (`docs/three-gates-scope.md:80-99`). +- **The pre-impact commit window.** `docs/three-gates-scope.md:92-97` rules that the + outcome commits a short fixed time before impact and that fire control owns the horizon + as one authored setting; `docs/shield-presentation-contract.md` binds presentations to + it. Nothing in this campaign reads or writes that horizon. The one thing to be careful + of: a weapon's resolved stats at the moment of the roll and at the moment of impact may + differ, because heat and power move in between. **Fire control must snapshot what it + rolled over at commit time.** The resolver hands out a value; it does not promise that + value is still current a second later. That is the seam, stated as the rule fire + control has to keep. +- Whether a starved weapon that fires anyway should roll worse, or simply not fire. This + campaign makes it not fire (Cut 4: no fraction of a shot). If fire control wants a + degraded shot instead, that is a ruling against this map's Q4/Cut 4, and it should be + raised there rather than worked around downstream. + +## 9. Known gaps in this map + +Stated so Soul does not have to find them. + +- Only **three** hulls generate a loadout at HEAD, so §0.4's per-ship measurement rests + on a small sample. A real fight may be much heavier than 7 entities at 20 + evaluations/tick. +- The entity leak in §0.3 is proven from the code, not from a memory profile. O1 is where + it becomes a measured fact. +- The per-tick evaluation table in §0.4 was counted from behaviour bodies by hand and + applied to real loadouts by the probe. A behaviour whose `Execute` returns early most + ticks is overcounted; `Thruster` and `Radiator` are the likely ones. +- Cut 3 leaves four instant draw sites spending directly for exactly one cut. That is a + named, bounded split of authority, and if Cut 4 does not follow closely it becomes the + thing this doctrine forbids. +- The add estimates in §4 are guesses for types that do not exist yet. They are there to + be compared against reality in the postmortem, not to be trusted now.