Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/state_propagation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,42 @@ Use parent forwarding when all of the following are true:

For a nested dataclass field in the same state, call ``update_parameter()`` on
the dotted path instead.

What a resolved value is computed from
--------------------------------------

A resolved value of a state is computed from three things: which states are
registered at the scopes above it, what each of them stores for the field's config
type and field name, and what the global config stores. Each resolved snapshot (live
and saved) keeps the objects it was computed from (``ResolutionSources``): the global
config instance of the thread that resolved it and the object of every state
registered above. Every resolved read (``get_resolved_value``,
``get_saved_resolved_value``, ``get_provenance``, ``to_resolved_object``,
``state.fields``) first compares those objects with the ones the state would be given
now. When they are the same objects the read answers from the snapshot. When one is
another object, the two are compared field by field, the way field resolution reads a
context layer, and only the fields of the state that are unset and can inherit a
differing ``(config type, field)`` are resolved again. When other scopes have a
registered state than before, every field is.

So no caller announces a change. Registering, unregistering or replacing an ancestor,
editing, saving, restoring, undoing or redoing an ancestor's value, and replacing the
global config with ``set_live_global_config`` / ``set_saved_global_config`` all reach
a state on its next read, whether the state is registered or not. A registered state
is also told at once, so its subscribers hear of the change without a read; a state
that is not registered is referenced by nothing and tells its subscribers on its next
read.

Limits:

- Objects are compared by identity. A source that is changed in place is not seen.
``ObjectState.to_object`` returns the same function object for a state that wraps a
plain function, so states registered below a function-backed state follow its edits
only through the registry (that is, when they are registered themselves).
- The global config is stored per thread. A snapshot follows the global config of the
thread that resolved it; a read from another thread answers from the snapshot and
does not resolve it again for that thread's global config.
- A state built or resolved inside a ``config_context`` the caller entered takes that
context's layers into its snapshot and keeps them after the block
(`issue #53 <https://github.com/OpenHCSDev/ObjectState/issues/53>`_).

2 changes: 1 addition & 1 deletion src/objectstate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@
'ObjectStateEditSession',
]

__version__ = "3.0.2"
__version__ = "3.0.3"
__author__ = 'OpenHCS Team'
__description__ = 'Generic configuration framework for lazy dataclass resolution'

Expand Down
241 changes: 147 additions & 94 deletions src/objectstate/object_state.py

Large diffs are not rendered by default.

48 changes: 13 additions & 35 deletions src/objectstate/object_state_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,16 +447,16 @@ def unregister(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None:
def _resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None:
"""Tell registered states at or below ``changed_scopes`` that registration changed.

Each state compares the ancestors registered now with the ones its resolved
values were computed from (``ObjectState.resolve_again_if_ancestors_changed``),
so a state whose ancestors are the same does no work. This call only makes
the comparison happen now, so subscribers hear of changed values without
waiting for the next read.
Each state compares what it is resolved from now with what its resolved values
were computed from (``ObjectState.resolve_again_if_sources_changed``), so a
state whose ancestors are the same does no work. This call only makes the
comparison happen now, so subscribers hear of changed values without waiting
for the next read.
"""
changed_scopes = tuple(changed_scopes)
for state in tuple(cls._states.values()):
if any(scope.contains(state.scope_id) for scope in changed_scopes):
state.resolve_again_if_ancestors_changed()
state.resolve_again_if_sources_changed()

@classmethod
def registered_ancestors(
Expand Down Expand Up @@ -737,17 +737,10 @@ def get_ancestor_objects_with_scopes(
Returns:
List of (scope_id, object) tuples from ancestor scopes, ordered least→most specific.
"""
# Saved: the object as last saved, without delegate sync or reconstruction.
# Live: rebuilt from the current parameters; ``skip_delegate_sync`` decides
# whether the delegate is synchronised first.
return [
(
ancestor_key,
state._extraction_target
if use_saved
else state.to_object(
update_delegate=False, sync_delegate=not skip_delegate_sync
),
state.object_for_resolution(use_saved, sync_delegate=not skip_delegate_sync),
)
for ancestor_key, state in cls.registered_ancestors(scope_id)
]
Expand Down Expand Up @@ -882,29 +875,14 @@ def _invalidate_field_in_matching_states(
state.invalidate_field(dotted_path)
invalidated_paths.add(dotted_path)

# If invalidating saved baseline, remove from saved_resolved so it recomputes
if invalidate_saved and dotted_path in state._saved_resolved:
del state._saved_resolved[dotted_path]
if _debug:
logger.debug(f"Invalidated saved_resolved cache for {dotted_path}")

# Trigger recompute immediately to detect if resolved values actually changed.
# This ensures callbacks fire only when values change, not just when fields are invalidated.
# Prevents false flashes when Reset is clicked on already-reset fields.
if invalidated_paths:
changed_paths = state._ensure_live_resolved(notify_flash=False)

# If we invalidated saved baseline, recompute it now with fresh ancestor values
if invalidate_saved:
# Recompute entire saved_resolved snapshot to pick up new ancestor saved values
old_saved_resolved = state._saved_resolved
state._saved_resolved = state._compute_resolved_snapshot(use_saved=True)
logger.debug(f"Recomputed saved_resolved baseline after invalidation")

if old_saved_resolved != state._saved_resolved:
state._sync_materialized_state(changed_value_fields=changed_paths)
else:
state._sync_materialized_state(changed_value_fields=changed_paths)
# Without recorded sources the saved snapshot is resolved again in full.
state._resolved_from.pop(True, None)
# Resolve now, so callbacks fire only for values that really changed (no
# flash when Reset is clicked on a field that is already reset).
changed_paths = state._ensure_live_resolved(notify_flash=False)
state._sync_materialized_state(changed_value_fields=changed_paths)

# ========== REGISTRY-LEVEL TIME-TRAVEL (DAG Model) ==========

Expand Down
176 changes: 176 additions & 0 deletions src/objectstate/resolution_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""The objects a state's resolved values are computed from.

A resolved value of a state is a function of three things: which states are
registered above it, what each of them holds for the field's config type and
field, and what the global config holds. ``ResolutionSources`` is that input as
one value: the global config and the ancestors' objects, in the order the context
layers are entered. A resolved snapshot keeps the sources it was computed from;
it is current exactly while the state would be given the same objects today.

When the objects are not the same, ``fields_that_differ`` names the
``(config type, field name)`` pairs whose stored value is another one, read the
way field resolution reads a layer (``extract_all_configs``). Only fields that
can inherit one of those pairs need to be resolved again.

Objects are compared by identity. ``ObjectState.to_object`` builds a new object
whenever a parameter changed, and the global config setters store another
instance, so another identity is the signal that values may differ. Every state
below an edited ancestor asks about the same pair of objects, so the answer for
a pair is kept and computed once per edit.
"""

import threading
from collections import OrderedDict
from dataclasses import fields, is_dataclass
from typing import Any, NamedTuple

FieldKey = tuple[type, str]

# How many (old object, new object) answers are kept. Each entry holds both
# objects, so their ids cannot be given to other objects while it is kept.
_KEPT_PAIRS = 64
_differing_fields_of_pair: "OrderedDict[tuple[int, int], tuple[Any, Any, frozenset[FieldKey]]]" = (
OrderedDict()
)


class ResolutionSources(NamedTuple):
"""What one resolved snapshot of a state is computed from.

``global_config`` is the instance stored for the running thread, ``None`` when
none was set (the declared defaults are used then), and ``thread`` is that
thread: the global config is stored per thread, so two sources read in
different threads say nothing about each other's global config and are not
compared on it. A snapshot therefore follows a replaced global config in the
thread that resolved it, and a read from another thread never resolves it again
for that thread's global config. ``ancestors`` are the ``(scope id, object)``
pairs of the states registered above the state, least specific first, without
global config objects: the global config stands for them.
"""

thread: threading.Thread
global_config: Any | None
ancestors: tuple[tuple[str, Any], ...]

def same_objects_as(self, other: "ResolutionSources | None") -> bool:
"""Whether ``other`` holds the same objects at the same scopes."""
if other is None:
return False
if self.thread is other.thread and self.global_config is not other.global_config:
return False
if len(self.ancestors) != len(other.ancestors):
return False
for mine, theirs in zip(self.ancestors, other.ancestors): # on every resolved read
if mine[1] is not theirs[1] or mine[0] != theirs[0]:
return False
return True

def recorded_after(self, earlier: "ResolutionSources | None") -> "ResolutionSources":
"""What a snapshot is recorded as computed from once it follows these sources.

A snapshot brought up to date in another thread than the one that resolved it
was not compared on the global config, so it keeps the global config, and the
thread, it was resolved with.
"""
if earlier is None or earlier.thread is self.thread:
return self
return self._replace(thread=earlier.thread, global_config=earlier.global_config)

def fields_that_differ(self, earlier: "ResolutionSources | None") -> frozenset[FieldKey] | None:
"""The fields whose stored value differs between ``earlier`` and these sources.

``None`` means every field may differ: there is no ``earlier``, other scopes
have a registered state, or a global config was set where none was (or the
reverse).
"""
if earlier is None:
return None
same_thread = self.thread is earlier.thread
if same_thread and (earlier.global_config is None) != (self.global_config is None):
return None
if tuple(scope for scope, _ in earlier.ancestors) != tuple(
scope for scope, _ in self.ancestors
):
return None
differing: frozenset[FieldKey] = frozenset()
if same_thread and self.global_config is not earlier.global_config:
differing |= _fields_that_differ(earlier.global_config, self.global_config)
for (_, before), (_, now) in zip(earlier.ancestors, self.ancestors):
if before is not now:
differing |= _fields_that_differ(before, now)
return differing


def _fields_that_differ(before: Any, now: Any) -> frozenset[FieldKey]:
"""``(config type, field name)`` pairs stored differently in two layer objects."""
pair = (id(before), id(now))
kept = _differing_fields_of_pair.get(pair)
if kept is not None and kept[0] is before and kept[1] is now:
return kept[2]
differing = frozenset(_compare_layer_objects(before, now))
_differing_fields_of_pair[pair] = (before, now, differing)
while len(_differing_fields_of_pair) > _KEPT_PAIRS:
_differing_fields_of_pair.popitem(last=False)
return differing


def _compare_layer_objects(before: Any, now: Any) -> set[FieldKey]:
from objectstate.context_manager import extract_all_configs

configs_before = extract_all_configs(before)
configs_now = extract_all_configs(now)
held_before = {id(config) for config in configs_before.values()}
held_now = {id(config) for config in configs_now.values()}
differing: set[FieldKey] = set()
for config_type in configs_before.keys() | configs_now.keys():
config_before = configs_before.get(config_type)
config_now = configs_now.get(config_type)
if config_before is config_now:
continue
names = {
f.name for config in (config_before, config_now) if config is not None
for f in fields(config)
}
if config_before is None or config_now is None:
# A layer that starts or stops holding a config type changes where an
# unset field is reported to live, whatever the values are.
differing.update((config_type, name) for name in names)
continue
for name in names:
value_before = _stored(config_before, name)
value_now = _stored(config_now, name)
if id(value_before) in held_before and id(value_now) in held_now:
continue # a nested config on both sides: compared under its own type
if not _same_stored_value(value_before, value_now):
differing.add((config_type, name))
return differing


def _stored(config: Any, name: str) -> Any:
"""The raw field of a config, without lazy resolution; ``None`` when absent."""
if config is None:
return None
try:
return object.__getattribute__(config, name)
except AttributeError:
return None


def _same_stored_value(a: Any, b: Any) -> bool:
"""Equality of two stored values that never resolves a lazy field.

Dataclass instances are compared field by field on their stored values: their
own ``__eq__`` reads attributes, which resolves lazy fields in the caller's
context. A value whose comparison fails or is not a plain truth value counts
as different.
"""
if a is b:
return True
if type(a) is not type(b):
return False
if is_dataclass(a) and not isinstance(a, type):
return all(_same_stored_value(_stored(a, f.name), _stored(b, f.name)) for f in fields(a))
try:
return bool(a == b)
except Exception:
return False
16 changes: 12 additions & 4 deletions src/objectstate/scope_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from functools import lru_cache
from typing import ClassVar


Expand Down Expand Up @@ -69,10 +70,7 @@ def child(self, segment: str) -> ScopeKey:
def ancestors(self) -> tuple[ScopeKey, ...]:
"""The global scope, then each prefix of this key, ending with the key itself."""

parts = self.parts
return (ScopeKey(),) + tuple(
ScopeKey(self.SEPARATOR.join(parts[: index + 1])) for index in range(len(parts))
)
return _ancestors(self)

def contains(self, other: str | None) -> bool:
"""Whether ``other`` is this scope or below it."""
Expand All @@ -86,3 +84,13 @@ def strictly_contains(self, other: str | None) -> bool:
"""Whether ``other`` is below this scope and not the scope itself."""

return ScopeKey(other) != self and self.contains(other)


@lru_cache(maxsize=4096)
def _ancestors(scope: ScopeKey) -> tuple[ScopeKey, ...]:
"""``ScopeKey.ancestors``, kept per key: every resolved read of a state asks for it."""

parts = scope.parts
return (ScopeKey(),) + tuple(
ScopeKey(scope.SEPARATOR.join(parts[: index + 1])) for index in range(len(parts))
)
3 changes: 3 additions & 0 deletions src/objectstate/transaction_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class ObjectStateTransactionCheckpoint:
cached_object: Any
cached_object_applied: bool
invalid_fields: frozenset[str]
resolved_from: dict[bool, Any]
raw_dirty: bool
dirty_fields: frozenset[str]
signature_diff_fields: frozenset[str]
Expand Down Expand Up @@ -83,6 +84,7 @@ def capture(
cached_object=state._cached_object,
cached_object_applied=state._cached_object_applied,
invalid_fields=frozenset(state._invalid_fields),
resolved_from=dict(state._resolved_from),
raw_dirty=state._raw_dirty,
dirty_fields=frozenset(state._dirty_fields),
signature_diff_fields=frozenset(state._signature_diff_fields),
Expand Down Expand Up @@ -134,6 +136,7 @@ def restore(self, state: ObjectState, *, emit_notifications: bool = True) -> Non
state._cached_object = self.cached_object
state._cached_object_applied = self.cached_object_applied
state._invalid_fields = set(self.invalid_fields)
state._resolved_from = dict(self.resolved_from)
state._raw_dirty = self.raw_dirty
state._dirty_fields = set(self.dirty_fields)
state._signature_diff_fields = set(self.signature_diff_fields)
Expand Down
Loading
Loading