Skip to content

Commit b5dd8c2

Browse files
pdbethkeclaude
andcommitted
fix: fold membership is PRIVATE separation; close NOTELEPORT (sink cost ledger EMPTY)
Three Java-cited fixes, sink cost ledger 3 -> 0: - CustomPower parent folds: drop the ad-hoc 'not is_vpp(parent)' skip and skip PRIVATE modifiers instead, as engine/cost.py already does. Java's List.separatePrivateMods (List.java:53-60) removes private modifiers from getAssignedModifiers() at restore, so no fold ever sees them -- that separation, not any parent-hop difference, is why Ultrasonique's pool (commons PRIVATE="Yes") folds nothing while Yin Wu's folds. Proved with a temporary --probe-child oracle mode: HD's pool showed assignedMods=0 at runtime. - HDCLoader: absent PRIVATE falls back to the types-based default (Modifier.java:1136-1147 -- framework-typed means private). - Modifier.total_value reads the MINVAL-clamped levels property, as Java getTotalValue() calls getLevels() (GenericObject.java:1996-2002); and _template_data no longer defaults an absent MINVAL to 1 (_i's default is 1; MINVAL's is 0) -- the two together close NOTELEPORT (+1/4 from the level floor; CannotEscapeWithTeleport already zeroes the stated BASECOST at restore, CannotEscapeWithTeleport.java:127-131). Suite 1724 passed; corpus parity unchanged; stateful ledger 0, prototype 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PrVDXgpDfQpzEGErZTLjEs
1 parent 990091d commit b5dd8c2

5 files changed

Lines changed: 38 additions & 14 deletions

File tree

kirby_cost/io/hdc_loader.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1849,6 +1849,13 @@ def _build_modifier(self, elem, parent: GenericObject) -> Optional[Modifier]:
18491849
mod.private_mod = True
18501850
elif private_str.upper().startswith("N"):
18511851
mod.private_mod = False
1852+
else:
1853+
# Modifier.restoreFromSave (Modifier.java:1136-1147): no PRIVATE
1854+
# attribute falls back to the types-based default -- a modifier
1855+
# typed for a framework is private to it. HD always writes the
1856+
# attribute back out, so this only fires for hand-authored XML.
1857+
mod.private_mod = bool(
1858+
{"VPP", "MP", "EC", "LIST"} & set(mod.types or ()))
18521859

18531860
# Load sub-modifiers (modifiers on modifiers)
18541861
for sub_elem in elem.findall("MODIFIER"):

kirby_cost/objects/modifier.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,13 @@ def total_value(self) -> float:
135135
# Use getDoubleTotal() for modifier calculations
136136
total += adder.double_total()
137137

138-
# Add level costs
138+
# Add level costs. Java reads getLevels() -- the MINVAL-clamped
139+
# accessor -- not the raw field (Modifier.getTotalValue), so a stated
140+
# LEVELS below the template's MINVAL still prices at the floor:
141+
# NOTELEPORT (MINVAL=1, LVLCOST=.25) is +1/4 even when the document
142+
# writes no LEVELS at all.
139143
if self._level_value > 0.0:
140-
level_units = float(self._levels) / self._level_value
144+
level_units = float(self.levels) / self._level_value
141145
total += level_units * self._level_cost
142146

143147
# Apply advantages (positive nested modifiers)

kirby_cost/objects/powers/custom_power.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from kirby_cost.objects.powers.power import Power
1111
from kirby_cost.objects.base import GenericObject
1212
from kirby_cost.util.rounder import round_half_down, round_up
13-
from kirby_cost.objects.frameworks import is_multipower, is_elemental_control, is_vpp
13+
from kirby_cost.objects.frameworks import is_multipower, is_elemental_control
1414
from kirby_cost.objects.modifiers.linked import is_linked
1515
from typing import Optional
1616

@@ -155,6 +155,12 @@ def _compute_active_cost(self, exclude_xmlid: str = None) -> float:
155155
parent = self.main_power.parent
156156
if parent:
157157
for mod in parent.assigned_modifiers:
158+
# Private modifiers belong to the pool alone -- HD's List
159+
# removed them from getAssignedModifiers() at restore
160+
# (List.separatePrivateMods); this engine keeps one list, so
161+
# the skip is explicit, as in engine/cost.py.
162+
if getattr(mod, "private", False):
163+
continue
158164
if mod.types and "VPP" in mod.types:
159165
continue
160166
if mod.xmlid == "CHARGES" and is_multipower(parent):
@@ -198,8 +204,17 @@ def real_cost_pre_list(self) -> float:
198204
parent = self._parent
199205
if self.main_power:
200206
parent = self.main_power.parent
201-
if parent and not is_vpp(parent):
207+
# Java folds the parent List's limitations for VPP children too --
208+
# CustomPower.getRealCostPreList has no parent-type check. What keeps a
209+
# pool like Ultrasonique's from folding is that its commons are
210+
# PRIVATE="Yes": List.separatePrivateMods (List.java:53-60) moves
211+
# private modifiers OUT of getAssignedModifiers() at restore, so the
212+
# fold never sees them. This engine keeps both kinds in one list, so
213+
# the skip is explicit, as in engine/cost.py.
214+
if parent:
202215
for mod in parent.assigned_modifiers:
216+
if getattr(mod, "private", False):
217+
continue
203218
if mod.types and "VPP" in mod.types:
204219
continue
205220
if mod.xmlid == "CHARGES" and is_multipower(self._parent):
@@ -216,10 +231,12 @@ def real_cost_pre_list(self) -> float:
216231
if has_limitations:
217232
real_cost = round_half_down(real_cost)
218233

219-
# Minimum 1 CP
234+
# Minimum 1 CP -- Java's condition sees the COMPUTED getLevels()
235+
# (roundUp(baseCost), CustomPower.java:205-206 and :246-250), so a
236+
# stated LEVELS with baseCost 0 stays 0 and the floor does not fire.
220237
if (real_cost < 1.0 and
221238
(d > 0.0 or
222-
(self._levels > 0 and len(self.assigned_adders) == 0 and
239+
(self.levels > 0 and len(self.assigned_adders) == 0 and
223240
self.base_cost >= 0.0))):
224241
real_cost = 1.0
225242

kirby_cost/template/hdt_provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ def _template_data(entry: dict[str, Any], *, is_power: bool) -> TemplateData:
312312
level_cost=level_cost,
313313
level_value=level_value,
314314
level_power=_i(a, "LVLPOWER"),
315-
min_val=_i(a, "MINVAL"),
315+
min_val=_i(a, "MINVAL", 0),
316316
level_multiplier=_i(a, "LVLMULTIPLIER"),
317317
level_start=_i(a, "LEVELSTART"),
318318
continuing_effect=(a.get("CONTINUINGEFFECT") or "").upper().startswith("Y"),
Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
{
22
"_comment": "Shrink-only ledger for tests/test_validation_sink_costs.py. Regenerate the fixture: venv/bin/python -c 'from tests.validation_sink import write; write(\"/tmp/ValidationSink.hdc\")' ; (cd ../kirby-hd-oracle && ./hd6cli.sh /tmp/ValidationSink.hdc) > tests/fixtures/authored/ValidationSink.json. Seeded 2026-08-30: the corpus is HD-written and cannot see these; the hand-written sink can.",
3-
"_baseline": "23 sink cost values disagree with HD (2026-08-30); option-cost fix 2026-08-30: 13 closed, now 10 remain; 5E-fallback ruling + MULTIPLESFX state 2026-08-30: 5 closed, 5 new, now 10; REQUIRED-adder attach 2026-08-31: 2 closed (DoT), now 8; 2026-08-31 session: Images/DoT/VPP-slot cells closed (option sentinels, REQUIRED adders, shared-path fold); CustomPower fold membership reverted (corpus trade), now 3",
4-
"gaps": {
5-
"20260830000060:active_cost": "NakedModifier + NOTELEPORT: the template's MINVAL=1/LVLCOST=.25 now reaches TemplateData (min_val plumbed 2026-08-31) and Modifier.levels clamps on it, but the LOADED modifier still reports value 0 -- the remaining gap is in the naked-advantage path (NakedModifier sums only PRIVATE advantages; check isPrivate classification for a stated modifier) -- HD 15.0 engine 10 (NAKEDMODIFIER 'Naked Advantage')",
6-
"20260830000060:real_cost": "NakedModifier + NOTELEPORT: the template's MINVAL=1/LVLCOST=.25 now reaches TemplateData (min_val plumbed 2026-08-31) and Modifier.levels clamps on it, but the LOADED modifier still reports value 0 -- the remaining gap is in the naked-advantage path (NakedModifier sums only PRIVATE advantages; check isPrivate classification for a stated modifier) -- HD 15.0 engine 10 (NAKEDMODIFIER 'Naked Advantage')",
7-
"20260830000061:real_cost": "CustomPower real-cost: the engine's min-1 reads raw _levels (stated 5) where Java's condition sees computed getLevels()==roundUp(baseCost)==0 (CustomPower.java:246-250); the fix rode with the parent-fold membership change that traded ULTRASONIQUE for DOCTOR_YIN_WU and was reverted -- CustomPower.getRealCostPreList folds only the parentID-based getParentList(), never the transient parent CompoundPower sets while summing; needs a membership-based port -- HD 0.0 engine 1.0 (CUSTOMPOWER 'Custom Power With END')"
8-
}
9-
}
3+
"_baseline": "23 sink cost values disagree with HD (2026-08-30); option-cost fix 2026-08-30: 13 closed, now 10 remain; 5E-fallback ruling + MULTIPLESFX state 2026-08-30: 5 closed, 5 new, now 10; REQUIRED-adder attach 2026-08-31: 2 closed (DoT), now 8; 2026-08-31 session: Images/DoT/VPP-slot cells closed (option sentinels, REQUIRED adders, shared-path fold); CustomPower fold membership reverted (corpus trade), now 3; fold-membership port 2026-08-31 (PRIVATE separation is the mechanism -- List.separatePrivateMods -- not a parent-hop difference): CustomPower cell closed, now 2; NOTELEPORT close 2026-08-31 (CannotEscapeWithTeleport zeroes stated BASECOST at restore -- already ported; the real gaps were total_value reading raw _levels where Java getTotalValue calls the MINVAL-clamped getLevels(), and _i defaulting an absent MINVAL to 1 instead of 0): now 0 -- EMPTY, the ledger's job is to stay that way",
4+
"gaps": {}
5+
}

0 commit comments

Comments
 (0)