VisibilityMask has no value equality or hash — identical masks compare unequal #405
Labels
No labels
Alpha Release Requirement
Bugfix
Demo Target
Documentation
Major Feature
Minor Feature
priority:tier1-active
priority:tier2-foundation
priority:tier3-future
priority:tier4-deferred
Refactoring & Cleanup
system:animation
system:documentation
system:grid
system:input
system:performance
system:procgen
system:python-binding
system:rendering
system:ui-hierarchy
Tiny Feature
workflow:blocked
workflow:needs-benchmark
workflow:needs-documentation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
john/McRogueFace#405
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
mcrfpy.VisibilityMask(src/PyVisibilityMask.h/.cpp, branchsim/integration, not yet on master) is described in its own docstring as "an immutable, storable record of which cells were visible, from where, at which frame" — created only byGridData.observe(), meant to be kept around for fog-of-war persistence, replaying an AI's past perception, or checking a recorded belief state against ground truth.The problem:
mcrfpydef::PyVisibilityMaskType(src/PyVisibilityMask.cpp, the type object literal starting ~line 172) sets neithertp_richcomparenortp_hash, and there's notp_weaklistoffseteither. That means:object's defaulttp_richcompare/tp_hash, i.e. identity equality and identity hash.VisibilityMaskinstances with identicalorigin,fov_radius,tick,cells(andgrid_size/light_walls/algorithm) compare unequal with==, and hash to different values.Why it matters: the whole point of this type is to be a comparable, storable value. The obvious thing a caller will write —
old_belief == new_observationto check whether an agent's recorded perception still matches what it can see now, or deduplicating a history of masks in aset/asdictkeys — silently does the wrong thing. It won't raise; it'll just always answerFalse(or, for a set/dict, never collapse duplicates), because every mask isis-compared instead of compared by content. That's a correctness trap precisely for the persistence/replay use cases the type exists for.Urgency note: this costs nothing to fix now, before the type has shipped on master or in a release. Adding
tp_richcompare/tp_hashafter external code exists that relies on (or accidentally depends on) identity semantics is a behavior change on an already-released type — e.g. code that stuffed masks into adictkeyed byid()-equivalent semantics, or that used==as an (accidental) identity check, would change behavior silently. Doing this before release is free; doing it after is not.Open design question — what participates in equality?
cells(the frozenset) — obviously yes, this is the payload.origin,fov_radius,grid_size— presumably yes, they're part of what makes the record meaningful (two masks with the same cells but computed from a different origin/radius/grid describe different things, even if they happen to coincide on visible cells).tick— probably yes, and worth calling out explicitly: two masks with identicalcells/origin/fov_radiustaken at different frames are arguably still different records (one is stale, one is current), even though "what was visible" is the same set. If replay/history code wants to dedupe by content-ignoring-time, that should be a different comparison than==(e.g. a.same_visibility_as(other)method), not the default equality.light_walls/algorithm— unclear, and this directly overlaps with #404 (VisibilityMask recordslight_walls/algorithmeven when no FOV computation ran, e.g. for an unbounded mask). If #404 is resolved by reportingNonefor those fields when they didn't participate in computing the mask, then two unbounded masks would naturally compare equal on those fields regardless of what defaults were passed in — which is the right outcome. Whatever fields end up participating in==/hashshould be decided together with #404's fix, not independently, since the same "did this field actually affect this mask" question underlies both.Suggested implementation shape (not prescriptive):
tp_richcomparedoing element-wise comparison oforigin,fov_radius,tick,grid_size,cells(and whatever #404 decides forlight_walls/algorithm), only forPy_EQ/Py_NE(raise/returnNotImplementedfor ordering comparisons — there's no natural order for a visibility record);tp_hashcombining hashes of the same fields via something likePyObject_Hashcomposition (careful: hashing a frozenset of tuples is fine, but skip fields that could be unhashable). Addtp_weaklistoffset+ aPyObject* weakreflistmember toPyVisibilityMaskObjectwhile at it, following the existing shared_ptr-wrapper pattern used elsewhere in the codebase (PyObject_HEAD+ ... +PyObject* weakreflist).Cross-reference: #404 (same type,
light_walls/algorithmrecorded even when no FOV computation ran) — resolve together, since both hinge on "which fields are actually meaningful parts of the record."Not fixed yet; raised by a code reviewer during work on the observation seam.