On this page

SpellIndex๏ƒ

Use md.SpellIndex from the public package namespace.

Implementation source

class SpellIndex(initial_id: str, *, binding_signature: Tuple[str, str, str] | None = None, binding_text: str | None = None)[source]๏ƒ

A stable SpellIndex identity that points to a mutable selected-spell id and holds the set of member spell ids that belong to this index.

Design: This class solves the "mutable dictionary key" problem. It provides a stable, hashable SpellIndex identity via an immutable ULID, while tracking both a mutable selected-spell pointer (the SHA256 id of the active spell) and the set of member spell ids the index contains.

  • Hashing and equality are based only on the immutable ULID.

  • The selected-spell pointer can be safely updated (mutated) in a thread-safe manner without breaking its location in a dictionary.

  • The index can hold multiple member spell ids; selected_spell_id is the one currently active. The index organizes its member ids; the spell_id resolves. Lookup-map propagation to owning/contracted Spellbooks is the Spellbook's responsibility (the notch seam), not the index's.

Contract:

  • Hash and equality derive ONLY from the immutable ULID.

  • selected_spell_id is mutable and thread-safe to update.

  • The index owns its member spell ids; it does NOT own lookup-map propagation to owning or contracted Spellbooks - that is the Spellbook's notch seam.

  • THE INDEX KEEPS ITS FIRST VERSION'S BINDING (2026-10-25; STORY-2026-10-17-notch_moves_signature_by_incoming_key_only, the owner's rule: "if you want to use notch you gotta use the signature that was used originally"). binding_signature is the kind of spellframe the version it was built for was bound with (none, a string category, a Protocol) and the address that gives (frame key and binding key, case-insensitive). Every version of the index is bound the same way: check_admits refuses any other, and the Spellbook runs it before a version is staged onto the index (bind_inactive) or moved into it (add_to_spell_index). So an index's address never moves, a notch only changes which version serves it, and a renamed class or a new binding name is a new spell.

Threading:

The selected-spell pointer is updated under lock, so a repoint is safe while the index sits in a dictionary.

Registration:

MELDER KERNEL - guarded. Created by Bind; users receive indexes rather than constructing them.

Subsystem Context:

The stable identity spells are organized under. Spell is one version; the index organizes versions and selects one of them. Version HISTORY belongs to MutationResearch, not here.

System Context:

The design note names the real problem this solves: the MUTABLE DICTIONARY KEY. Identity must stay stable so the object keeps its place in every map that holds it, while the thing it POINTS AT must be free to change. Hashing on the immutable ULID and mutating only the pointer is what makes a notch possible at all - repointing an index cannot corrupt the maps it lives in. That split ripples outward. Contracts carry BOTH a Detail (a captured spell_id, the answer at grant time) and an IndexDetail (a subscription to this index, following the head), so a notch updates every borrower without renegotiating a single contract. The crystallizer records index MEMBERSHIP as its own twin (SpellIndexCrystal) for the same reason - membership is the index's own truth, distinct from any one spell's custody. The explicit non-ownership of lookup-map propagation is the boundary that keeps this class small: the index organizes ids, the spell_id resolves, and the Spellbook is what republishes lookups on a notch.

AGENT_ACCESS: public

AGENT_PURPOSE:

access: public. The stable index identity (immutable ULID) pointing at a mutable selected spell. Hash/equality use only the ULID, so a notch can repoint the active member without breaking any map. Version history belongs to MutationResearch.

classmethod for_binding(initial_id: str, *, spellframe: Any, spell_name: str, binding_name: str | None) → SpellIndex[source]๏ƒ

Build the index for a version's binding, the index's first version.

Purpose:

The one way Melder builds an index (Bind._bind_logic for every Spell it creates; Spellbook._apply_remove_from_index for a version split off into an index of its own): the index records the binding of its first version, and every later version must be bound the same way.

Contract:

  • The index selects and contains initial_id, as the constructor's.

  • binding_signature is signature_of(...) of the three values and the refusal text describes them (_describe_binding).

  • The values are taken as Bind accepted them: spellframe is None, a string or a Protocol class (Bind._classify_spellframe refuses anything else before an index is built).

Parameters:
  • initial_id (str) -- The first version's spell_id.

  • spellframe (Any) -- The first version's spellframe - None, a string category or a Protocol class.

  • spell_name (str) -- The first version's name (the class or function name Bind took).

  • binding_name (Optional[str]) -- The first version's binding name, as passed (None or "" for the default).

Returns:

A new index keeping that binding.

Return type:

SpellIndex

static signature_of(*, spellframe: Any, spell_name: str, binding_name: str | None) → Tuple[str, str, str][source]๏ƒ

Return the binding signature of a version bound with these values.

Purpose:

The comparable form of a binding: what kind of spellframe it was bound with and the address that gives. Two versions belong on one index exactly when their signatures are equal.

Contract:

  • The kind is a SpellframeKind value: "none" for no spellframe, "category" for a string, "contract" for anything else - the Protocol class Bind accepted (Bind._classify_spellframe).

  • The address is SpellInputUtils.make_spell_key_from_parts(...), the (frame_key, binding_key) Spell._key holds and the frame claim is keyed by: the spellframe's name, or the spell's own name when bound bare, and the binding name, both lowercased; an empty binding name is the default binding.

  • So the kind keeps a string category spelled like a bare class's name apart from that class, and two Protocols of one name give one signature, as they give one address.

  • Pure: reads its arguments, changes nothing.

Parameters:
  • spellframe (Any) -- None, a string category or a Protocol class.

  • spell_name (str) -- The version's name (the address label when bound bare).

  • binding_name (Optional[str]) -- The binding name, or None for the default.

Returns:

(kind, frame_key, binding_key).

Return type:

Tuple[str, str, str]

Raises:

TypeError -- Propagated from the key normalizers for a value they cannot read.

cleanup() → None[source]๏ƒ

Release the member set and pointer and mark this index as cleaned.

Contract:

  • Idempotent and lock-guarded.

  • Clears and drops the member set and the selected-spell pointer.

  • Keeps the binding (binding_signature), like the id: immutable values that a reader racing this cleanup must never find deleted.

  • Leaves future callers to fail through check_cleaned().

Returns:

None.

property selected_spell_id: str๏ƒ

Return the active spell's id for this SpellIndex.

Returns:

The active spell's id.

Return type:

str

Contract:

  • Returns the live selected-spell pointer, not a historical value.

  • Lock-free read: the pointer is one attribute holding one string reference, so a reader observes either the previous or the new id, never a torn value. update(...) still serializes writers under the instance lock.

Threading:

  • Safe on free-threaded builds; attribute reference loads are atomic. This property is the hottest spell_id read in the compiler phases (thousands of reads per conjure), and the per-read RLock acquire/release dominated its cost on nogil.

update(new_id: str) → None[source]๏ƒ

Set the active spell id and add it to the index's member set.

This is a pure index-side repoint: it changes which member is active and records new_id in the member set, and nothing else. Owning and contracted Spellbook lookup-map propagation is the Spellbook's responsibility (the notch seam), not the index's. Thread-safe; does not affect the object's hash or its dictionary location.

Parameters:

new_id (str) -- The new SHA256 spell id to select and add as a member.

Contract:

  • SELECTS a new current spell id AND adds it to the member set, so selection always implies membership.

  • DOES NOT RETIRE THE PREVIOUS ID. The superseded version stays a MEMBER - parked in its Book - so the member set holds every version and the selected id names only the selected one. A lookup by spell_id (Spellbook.find_spell_by_spell_id) answers the superseded spell itself, never the head; the head is what selected_spell_id and a lookup by index id name.

Threading:

Reads and writes under self._lock, so the result is a coherent snapshot rather than a torn read.

Lifecycle / Cleanup:

Guarded by check_cleaned().

Raises:

RuntimeError -- If the object has been cleaned.

Returns:

None.

add_member(spell_id: str) → None[source]๏ƒ

Add spell_id to this index's member set without selecting it.

Used to stage an inactive candidate (Spellbook.bind_inactive): the id becomes a member visible to spells_in_index() / has_spell(), but the active selected_spell_id is left unchanged. Promotion to active happens later via notch.

Parameters:

spell_id (str) -- The candidate member spell id to record.

Contract:

  • Adds to the index WITHOUT changing the selected id, so it grows the version set without promoting anything.

  • Set semantics: re-adding an existing id is a silent no-op.

Threading:

Reads and writes under self._lock, so the result is a coherent snapshot rather than a torn read.

Lifecycle / Cleanup:

Guarded by check_cleaned().

Raises:

RuntimeError -- If the object has been cleaned.

Returns:

None.

remove_member(spell_id: str) → None[source]๏ƒ

Remove spell_id from this index's member set.

Idempotent; does not touch the selected pointer. Used when a member is moved out of this index (add_to_spell_index).

Parameters:

spell_id (str) -- The member spell id to remove.

Contract:

  • Uses discard, so removing an id that was never a member is a SILENT NO-OP rather than an error. Absence of an exception is not proof the id was present.

  • CAN REMOVE THE CURRENTLY SELECTED ID. Nothing here re-points the selection, so afterwards selected_spell_id may name an id that is no longer a member. Callers retiring the head must select a new one.

Threading:

Reads and writes under self._lock, so the result is a coherent snapshot rather than a torn read.

Lifecycle / Cleanup:

Guarded by check_cleaned().

Raises:

RuntimeError -- If the object has been cleaned.

Returns:

None.

spells_in_index() → set[source]๏ƒ

Return a snapshot of every member spell id in this index.

Returns:

A detached copy of the member spell ids.

Return type:

set

Contract:

  • Includes the initial spell id and every id added via update(...).

has_spell(spell_id: str) → bool[source]๏ƒ

Return whether spell_id is a member of this index.

Parameters:

spell_id (str) -- The spell id to check.

Contract:

  • Tests MEMBERSHIP OF THE INDEX, not equality with the selected id, so a superseded or parked version still answers True. A membership test only: a spell_id names its own spell (Spellbook.find_spell_by_spell_id), not the index's selected spell.

Threading:

Reads and writes under self._lock, so the result is a coherent snapshot rather than a torn read. The cleaned check is repeated under that lock, the one cleanup() marks the index cleaned and deletes the member set under, so a cleanup racing this call raises the documented RuntimeError, never AttributeError (0.2.8230).

Lifecycle / Cleanup:

Guarded by check_cleaned(), before and after taking the lock.

Raises:

RuntimeError -- If the object has been cleaned.

Returns:

True if spell_id is in the member set, False otherwise.

Return type:

bool

is_empty() → bool[source]๏ƒ

Return whether this index has no members.

O(1): tests the live member set directly without copying it.

Contract:

  • Reports that the MEMBER SET is empty. It says nothing about the selected id, which member removal does not clear - an index can be empty and still carry a stale selected id.

Threading:

Reads and writes under self._lock, so the result is a coherent snapshot rather than a torn read.

Lifecycle / Cleanup:

Guarded by check_cleaned().

Raises:

RuntimeError -- If the object has been cleaned.

Returns:

True if the member set is empty.

Return type:

bool

is_sole_member(spell_id: str) → bool[source]๏ƒ

Return whether spell_id is this index's only member.

O(1): a length test plus a membership test on the live set; no copy and no set construction (unlike spells_in_index() == {spell_id}).

Parameters:

spell_id (str) -- The spell id to test as the sole member.

Contract:

  • True only when the member set has exactly one entry AND that entry is the supplied id; a different lone member returns False rather than raising.

  • The usual "is this the last version" test before a destructive step.

Threading:

Reads and writes under self._lock, so the result is a coherent snapshot rather than a torn read.

Lifecycle / Cleanup:

Guarded by check_cleaned().

Raises:

RuntimeError -- If the object has been cleaned.

Returns:

True iff the member set is exactly {spell_id}.

Return type:

bool

property binding_signature: Tuple[str, str, str] | None๏ƒ

Return the binding every version of this index is bound with.

Purpose:

The binding of the version the index was built for: the kind of spellframe (none, a string category, a Protocol) and the address it gives. Every version of the index is bound the same way, so this is also the address the index serves.

Contract:

  • (kind, frame_key, binding_key), as signature_of builds it - for example ("contract", "greeter", "english") for a version bound under the Protocol Greeter with binding name "English".

  • None for an index built without a binding (not by Melder).

  • Never changes; kept after cleanup, like id.

Threading:

Lock-free read of a write-once slot; safe from any thread.

Returns:

The signature, or None.

Return type:

Optional[Tuple[str, str, str]]

check_admits(spell: Spell, *, verb: str) → None[source]๏ƒ

Refuse a version bound another way than this index's versions.

Purpose:

The check that keeps every version of an index bound the way its first version was (the owner's rule, 2026-10-25). The Spellbook runs it before staging a version onto the index (bind_inactive) and before moving one into it (add_to_spell_index), so a refusal leaves nothing registered or moved.

Contract:

  • Compares signature_of(...) of the version's spellframe, spell_name and binding_name with binding_signature. Equal admits - the case of the names aside, since the address is case-insensitive. Reads those three fields and spell_id, and nothing else; changes nothing.

  • A different kind of spellframe is another binding even at the same address, and a renamed class bound bare is another address: each is a new spell, to be bound on its own.

Parameters:
  • spell (Spell) -- The version to stage or move.

  • verb (str) -- The calling verb, which opens the refusal message.

Returns:

None.

Raises:
  • ValueError -- "<verb> refused: SpellIndex <id> keeps the binding its first version was bound with - <this index's binding> - and '<name>' (spell_id <id>) is bound with <the version's>. ...", with the remedy.

  • RuntimeError -- If the index records no binding (built without for_binding - a Melder bug), or has been cleaned.

Threading:

The binding is read without the lock; check_cleaned() guards the call.

property id: str๏ƒ

Return the immutable ULID that defines this index's stable identity.

Contract:

  • This value never changes for the lifetime of the index.

  • Hashing and equality are derived from this id, not from the mutable selected-spell pointer.

Returns:

The immutable ULID. Hash and equality derive from this alone, which

is what lets the selected spell move without breaking dictionary placement.

Return type:

str

Topic reference ยท Full contents